Skip to content
Merged
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
9 changes: 9 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@
## Version 0.2.0 (in development)

- Make all docstrings comply to google-style
- Rule description is now your `RuleOp`'s docstring
if `description` is not explicitly provided.
- Supporting _virtual plugins_: plugins provided by Python
dictionaries with rules defined by the `RuleOp` classes.
- Added more configuration examples in the `examples` folder.
- Introduced utilities `ValueConstructible` and
derived `MappingConstructible` which greatly simplify
flexible instantiation of configuration objects and their
children from Python and JSON/YAML values.

## Version 0.1.0 (09.01.2025)

Expand Down
11 changes: 9 additions & 2 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,23 @@ This chapter provides a plain reference for the XRLint Python API.
- The `config` module provides classes that represent
configuration information and provide related functionality:
[Config][xrlint.config.Config] and [ConfigList][xrlint.config.ConfigList].
- The `rule` module provides rule related classes:
- The `rule` module provides rule related classes and functions:
[Rule][xrlint.rule.Rule] comprising rule metadata
[RuleMeta][xrlint.rule.RuleMeta] and the rule operation
[RuleOp][xrlint.rule.RuleOp], as well as related to the latter
[RuleContext][xrlint.rule.RuleContext] and [RuleExit][xrlint.rule.RuleExit].
Decorator [define_rule][xrlint.rule.define_rule] allows defining rules.
- The `node` module defines the nodes passed to [xrlint.rule.RuleOp]:
base classes [None][xrlint.node.Node], [XarrayNode][xrlint.node.XarrayNode]
and the specific [DatasetNode][xrlint.node.DatasetNode],
[DataArray][xrlint.node.DataArrayNode], [AttrsNode][xrlint.node.AttrsNode],
and [AttrNode][xrlint.node.AttrNode] nodes.
- The `processor` module provides processor related classes:
- The `processor` module provides processor related classes and functions:
[Processor][xrlint.processor.Processor] comprising processor metadata
[ProcessorMeta][xrlint.processor.ProcessorMeta]
and the processor operation [ProcessorOp][xrlint.processor.ProcessorOp].
Decorator [define_processor][xrlint.processor.define_processor] allows defining
processors.
- The `result` module provides data classes that are used to
represent validation results:
[Result][xrlint.result.Result] composed of [Messages][xrlint.result.Message],
Expand All @@ -51,6 +54,8 @@ Note: the `xrlint.all` convenience module exports all of the above from a

::: xrlint.config.ConfigList

::: xrlint.rule.define_rule

::: xrlint.rule.Rule

::: xrlint.rule.RuleMeta
Expand All @@ -77,6 +82,8 @@ Note: the `xrlint.all` convenience module exports all of the above from a

::: xrlint.plugin.PluginMeta

::: xrlint.processor.define_processor

::: xrlint.processor.Processor

::: xrlint.processor.ProcessorMeta
Expand Down
2 changes: 1 addition & 1 deletion environment.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: xrlint
name: xrlint-310
channels:
- conda-forge
dependencies:
Expand Down
Empty file added examples/__init__.py
Empty file.
27 changes: 18 additions & 9 deletions examples/xrlint_config.py → examples/plugin_config.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
# XRLint configuration file example
"""
This configuration example shows how to define and use a plugin
using the `Plugin` class and its `define_rule()` decorator method.
"""

from xrlint.config import Config
from xrlint.node import DatasetNode
Expand All @@ -11,21 +14,24 @@
plugin = Plugin(
meta=PluginMeta(name="hello-plugin", version="1.0.0"),
configs={
# "configs" entries must be `Config` objects!
"recommended": Config.from_value(
{
"rules": {
"hello/good-title": "error",
"hello/good-title": "warn",
# Configure more rules here...
},
}
)
),
# Add more configurations here...
},
)


@plugin.define_rule(
"good-title", description=f"Dataset title should be 'Hello World!'."
)
@plugin.define_rule("good-title")
class GoodTitle(RuleOp):
"""Dataset title should be 'Hello World!'."""

def dataset(self, ctx: RuleContext, node: DatasetNode):
good_title = "Hello World!"
if node.dataset.attrs.get("title") != good_title:
Expand All @@ -35,16 +41,19 @@ def dataset(self, ctx: RuleContext, node: DatasetNode):
)


# Define more rules here...


def export_configs():
return [
{
"files": ["**/*.zarr", "**/*.nc"],
},
# Use "hello" plugin
{
"plugins": {
"hello": plugin,
},
},
# Use recommended settings from xrlint
"recommended",
# Use recommended settings from "hello" plugin
"hello/recommended",
]
51 changes: 51 additions & 0 deletions examples/rule_testing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""
This example demonstrates how to develop new rules.
"""

import xarray as xr

from xrlint.node import DatasetNode
from xrlint.rule import RuleContext
from xrlint.rule import RuleOp
from xrlint.rule import define_rule
from xrlint.testing import RuleTest
from xrlint.testing import RuleTester


@define_rule("good-title")
class GoodTitle(RuleOp):
"""Dataset title should be 'Hello World!'."""

def dataset(self, ctx: RuleContext, node: DatasetNode):
good_title = "Hello World!"
if node.dataset.attrs.get("title") != good_title:
ctx.report(
"Attribute 'title' wrong.",
suggestions=[f"Rename it to {good_title!r}."],
)


# -----------------
# In another module
# -----------------

tester = RuleTester()

valid_dataset = xr.Dataset(attrs=dict(title="Hello World!"))
invalid_dataset = xr.Dataset(attrs=dict(title="Hello Hamburg!"))

# Run test directly
tester.run(
"good-title",
GoodTitle,
valid=[RuleTest(dataset=valid_dataset)],
invalid=[RuleTest(dataset=invalid_dataset)],
)

# or define a test class derived from unitest.TestCase
GoodTitleTest = tester.define_test(
"good-title",
GoodTitle,
valid=[RuleTest(dataset=valid_dataset)],
invalid=[RuleTest(dataset=invalid_dataset)],
)
57 changes: 57 additions & 0 deletions examples/virtual_plugin_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""
This configuration example demonstrates how to
define and use "virtual" plugins. Such plugins
can be defined inside a configuration item.
"""

from xrlint.node import DatasetNode
from xrlint.rule import RuleContext
from xrlint.rule import RuleOp
from xrlint.rule import define_rule


@define_rule("good-title", description="Dataset title should be 'Hello World!'.")
class GoodTitle(RuleOp):
def dataset(self, ctx: RuleContext, node: DatasetNode):
good_title = "Hello World!"
if node.dataset.attrs.get("title") != good_title:
ctx.report(
"Attribute 'title' wrong.",
suggestions=[f"Rename it to {good_title!r}."],
)


# Define more rules here...


def export_configs():
return [
# Define and use "hello" plugin
{
"plugins": {
"hello": {
"meta": {
"name": "hello",
"version": "1.0.0",
},
"rules": {
"good-title": GoodTitle,
# Add more rules here...
},
"configs": {
"recommended": {
"rules": {
"hello/good-title": "warn",
# Configure more rules here...
},
},
# Add more configurations here...
},
},
}
},
# Use recommended settings from xrlint
"recommended",
# Use recommended settings from "hello" plugin
"hello/recommended",
]
4 changes: 2 additions & 2 deletions tests/cli/configs/recommended.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
- recommended
- xcube/recommended
- rules:
"dataset-title-attr": error
"xcube/single-grid-mapping": off
"dataset-title-attr": "error"
"xcube/single-grid-mapping": "off"
11 changes: 7 additions & 4 deletions tests/cli/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ def test_read_config_invalid_arg(self):
with pytest.raises(
TypeError,
match="configuration file must be of type str|Path|PathLike,"
" but was None",
" but got None",
):
# noinspection PyTypeChecker
read_config_list(None)
Expand Down Expand Up @@ -125,7 +125,7 @@ def test_read_config_yaml_with_type_error(self):
match=(
"'config.yaml: configuration list must be of"
" type ConfigList|list\\[Config|dict|str\\],"
" but was dict'"
" but got dict'"
),
):
read_config_list(config_path)
Expand All @@ -142,7 +142,10 @@ def test_read_config_py_no_export(self):
with text_file(self.new_config_py(), py_code) as config_path:
with pytest.raises(
ConfigError,
match=".py: missing export_configs()",
match=(
"config_1002.py: attribute 'export_configs'"
" not found in module 'config_1002'"
),
):
read_config_list(config_path)

Expand Down Expand Up @@ -173,7 +176,7 @@ def test_read_config_py_with_invalid_config_list(self):
".py: return value of export_configs\\(\\):"
" configuration list must be of type"
" ConfigList|list\\[Config|dict|str\\],"
" but was int"
" but got int"
),
):
read_config_list(config_path)
Expand Down
2 changes: 1 addition & 1 deletion tests/cli/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ def test_print_config_option(self):
"{\n"
' "name": "<computed>",\n'
' "plugins": {\n'
' "__core__": "xrlint.plugins.core"\n'
' "__core__": "xrlint.plugins.core:export_plugin"\n'
" },\n"
' "rules": {\n'
' "dataset-title-attr": 2\n'
Expand Down
47 changes: 39 additions & 8 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import pytest

from xrlint.config import Config, ConfigList
from xrlint.rule import RuleConfig
from xrlint.util.filefilter import FileFilter


Expand Down Expand Up @@ -42,33 +43,61 @@ def test_from_value_ok(self):
}
),
)
self.assertEqual(
Config(
rules={
"hello/no-spaces-in-titles": RuleConfig(severity=2),
"hello/time-without-tz": RuleConfig(severity=0),
"hello/no-empty-units": RuleConfig(
severity=1, args=(12,), kwargs={"indent": 4}
),
},
),
Config.from_value(
{
"rules": {
"hello/no-spaces-in-titles": 2,
"hello/time-without-tz": "off",
"hello/no-empty-units": ["warn", 12, {"indent": 4}],
},
}
),
)

def test_from_value_fails(self):
with pytest.raises(
TypeError, match="configuration must be of type dict, but was int"
TypeError,
match=r"config must be of type Config \| dict \| None, but got int",
):
Config.from_value(4)

with pytest.raises(
TypeError, match="configuration must be of type dict, but was str"
TypeError,
match=r"config must be of type Config \| dict \| None, but got str",
):
Config.from_value("abc")

with pytest.raises(
TypeError, match="configuration must be of type dict, but was tuple"
TypeError,
match=r"config must be of type Config \| dict \| None, but got tuple",
):
Config.from_value(())

with pytest.raises(
TypeError,
match="linter_options must be of type dict\\[str,Any\\], but was list",
match=r" config.linter_options must be of type dict.*, but got list",
):
Config.from_value({"linter_options": [1, 2, 3]})

with pytest.raises(
TypeError, match="settings keys must be of type str, but was int"
TypeError,
match=r" keys of config.settings must be of type str, but got int",
):
Config.from_value({"settings": {8: 9}})


class ConfigListTest(TestCase):
def test_from_value(self):
def test_from_value_ok(self):
config_list = ConfigList.from_value([])
self.assertIsInstance(config_list, ConfigList)
self.assertEqual([], config_list.configs)
Expand All @@ -80,11 +109,13 @@ def test_from_value(self):
self.assertIsInstance(config_list, ConfigList)
self.assertEqual([Config()], config_list.configs)

# noinspection PyMethodMayBeStatic
def test_from_value_fail(self):
with pytest.raises(
TypeError,
match=(
"configuration list must be of type"
" ConfigList|list\\[Config|dict\\], but was dict"
r"config_list must be of type"
r" ConfigList \| list\[Config \| dict\], but got dict"
),
):
ConfigList.from_value({})
Expand Down
Loading
Loading