Skip to content

Commit 93fe355

Browse files
authored
Merge pull request #37 from bcdev/forman-improve_config_mgt
Improve configuration management
2 parents b1bba07 + 89825ac commit 93fe355

13 files changed

Lines changed: 383 additions & 328 deletions

File tree

CHANGES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
dictionary, or a name that refers to a named configuration of a plugin.
2424

2525
- Other changes:
26+
- Property `config` of `Linter` now returns a `ConfigList` instead
27+
of a `Config` object.
2628
- Directories that are recognized by file patterns associated with a non-empty
2729
configuration object are no longer recursively traversed.
2830
- Node path names now contain the dataset index if a file path

notebooks/mkdataset.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ def make_dataset() -> xr.Dataset:
3636
attrs={
3737
"standard_name": "time",
3838
"long_name": "time",
39-
"units": "days since 2020-01-01 utc",
39+
"units": "days since 2020-01-01 +0:00",
4040
"calendar": "gregorian",
4141
},
4242
),
@@ -71,7 +71,7 @@ def make_dataset_with_issues() -> xr.Dataset:
7171
invalid_ds.x.attrs["axis"] = "x"
7272
del invalid_ds.y.attrs["standard_name"]
7373
invalid_ds.y.attrs["axis"] = "y"
74-
invalid_ds.time.attrs["units"] = "days since 2020-01-01 ß0:000:00"
74+
invalid_ds.time.attrs["units"] = "days since 2020-01-01 UTC"
7575
invalid_ds.attrs = {}
7676
invalid_ds.sst.attrs["units"] = 1
7777
invalid_ds["sst_avg"] = xr.DataArray(

notebooks/xrlint-linter.ipynb

Lines changed: 143 additions & 186 deletions
Large diffs are not rendered by default.

tests/_linter/test_rulectx.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
# noinspection PyProtectedMember
66
from xrlint._linter.rulectx import RuleContextImpl
77
from xrlint.config import Config
8+
from xrlint.constants import NODE_ROOT_NAME
89
from xrlint.result import Message, Suggestion
910

1011

@@ -31,6 +32,7 @@ def test_report(self):
3132
[
3233
Message(
3334
message="What the heck do you mean?",
35+
node_path=NODE_ROOT_NAME,
3436
rule_id="no-xxx",
3537
severity=2,
3638
suggestions=[
@@ -39,6 +41,7 @@ def test_report(self):
3941
),
4042
Message(
4143
message="You said it.",
44+
node_path=NODE_ROOT_NAME,
4245
rule_id="no-xxx",
4346
severity=2,
4447
fatal=True,

tests/test_linter.py

Lines changed: 120 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33

44
import xarray as xr
55

6-
from xrlint.config import Config
7-
from xrlint.constants import CORE_PLUGIN_NAME
6+
from xrlint.config import Config, ConfigList
7+
from xrlint.constants import CORE_PLUGIN_NAME, NODE_ROOT_NAME
88
from xrlint.linter import Linter, new_linter
99
from xrlint.node import AttrNode, AttrsNode, DataArrayNode, DatasetNode
1010
from xrlint.plugin import new_plugin
@@ -16,36 +16,77 @@
1616
class LinterTest(TestCase):
1717
def test_default_config_is_empty(self):
1818
linter = Linter()
19-
self.assertEqual(Config(), linter.config)
19+
self.assertEqual(ConfigList(), linter.config)
2020

2121
def test_new_linter(self):
22-
import xrlint.all as xrl
23-
2422
linter = new_linter()
25-
self.assertIsInstance(linter, xrl.Linter)
26-
self.assertIsInstance(linter.config.plugins, dict)
27-
self.assertEqual({CORE_PLUGIN_NAME}, set(linter.config.plugins.keys()))
28-
self.assertEqual(None, linter.config.rules)
29-
30-
linter = new_linter(config_name=None)
31-
self.assertIsInstance(linter, xrl.Linter)
32-
self.assertIsInstance(linter.config.plugins, dict)
33-
self.assertEqual({CORE_PLUGIN_NAME}, set(linter.config.plugins.keys()))
34-
self.assertEqual(None, linter.config.rules)
23+
self.assertIsInstance(linter, Linter)
24+
self.assertEqual(1, len(linter.config.configs))
25+
config = linter.config.configs[0]
26+
self.assertIsInstance(config.plugins, dict)
27+
self.assertEqual({CORE_PLUGIN_NAME}, set(config.plugins.keys()))
28+
self.assertEqual(None, config.rules)
3529

30+
def test_new_linter_recommended(self):
3631
linter = new_linter("recommended")
37-
self.assertIsInstance(linter, xrl.Linter)
38-
self.assertIsInstance(linter.config.plugins, dict)
39-
self.assertEqual({CORE_PLUGIN_NAME}, set(linter.config.plugins.keys()))
40-
self.assertIsInstance(linter.config.rules, dict)
41-
self.assertIn("coords-for-dims", linter.config.rules)
32+
self.assertIsInstance(linter, Linter)
33+
self.assertEqual(2, len(linter.config.configs))
34+
config0 = linter.config.configs[0]
35+
config1 = linter.config.configs[1]
36+
self.assertIsInstance(config0.plugins, dict)
37+
self.assertEqual({CORE_PLUGIN_NAME}, set(config0.plugins.keys()))
38+
self.assertIsInstance(config1.rules, dict)
39+
self.assertIn("coords-for-dims", config1.rules)
4240

41+
def test_new_linter_all(self):
4342
linter = new_linter("all")
44-
self.assertIsInstance(linter, xrl.Linter)
45-
self.assertIsInstance(linter.config.plugins, dict)
46-
self.assertEqual({CORE_PLUGIN_NAME}, set(linter.config.plugins.keys()))
47-
self.assertIsInstance(linter.config.rules, dict)
48-
self.assertIn("coords-for-dims", linter.config.rules)
43+
self.assertIsInstance(linter, Linter)
44+
self.assertEqual(2, len(linter.config.configs))
45+
config0 = linter.config.configs[0]
46+
config1 = linter.config.configs[1]
47+
self.assertIsInstance(config0.plugins, dict)
48+
self.assertEqual({CORE_PLUGIN_NAME}, set(config0.plugins.keys()))
49+
self.assertIsInstance(config1.rules, dict)
50+
self.assertIn("coords-for-dims", config1.rules)
51+
52+
53+
class LinterVerifyConfigTest(TestCase):
54+
def test_config_with_config_list(self):
55+
linter = new_linter()
56+
result = linter.verify_dataset(
57+
xr.Dataset(),
58+
config=ConfigList.from_value([{"rules": {"no-empty-attrs": 2}}]),
59+
)
60+
self.assert_result_ok(result, "Missing metadata, attributes are empty.")
61+
62+
def test_config_with_list_of_config(self):
63+
linter = new_linter()
64+
result = linter.verify_dataset(
65+
xr.Dataset(),
66+
config=[{"rules": {"no-empty-attrs": 2}}],
67+
)
68+
self.assert_result_ok(result, "Missing metadata, attributes are empty.")
69+
70+
def test_config_with_config_obj(self):
71+
linter = new_linter()
72+
result = linter.verify_dataset(
73+
xr.Dataset(),
74+
config={"rules": {"no-empty-attrs": 2}},
75+
)
76+
self.assert_result_ok(result, "Missing metadata, attributes are empty.")
77+
78+
def test_no_config(self):
79+
linter = Linter()
80+
result = linter.verify_dataset(
81+
xr.Dataset(),
82+
)
83+
self.assert_result_ok(result, "No configuration given or matches '<dataset>'.")
84+
85+
def assert_result_ok(self, result: Result, expected_message: str):
86+
self.assertIsInstance(result, Result)
87+
self.assertEqual(1, len(result.messages))
88+
self.assertEqual(2, result.messages[0].severity)
89+
self.assertEqual(expected_message, result.messages[0].message)
4990

5091

5192
class LinterVerifyTest(TestCase):
@@ -88,6 +129,8 @@ class MultiLevelDataset(ProcessorOp):
88129
def preprocess(
89130
self, file_path: str, _opener_options: dict[str, Any]
90131
) -> list[tuple[xr.Dataset, str]]:
132+
if file_path == "bad.levels":
133+
raise OSError("bad checksum")
91134
return [
92135
(xr.Dataset(attrs={"title": "Level 0"}), file_path + "/0.zarr"),
93136
(xr.Dataset(attrs={"title": "Level 1"}), file_path + "/1.zarr"),
@@ -99,7 +142,7 @@ def postprocess(
99142
return messages[0] + messages[1]
100143

101144
config = Config(plugins={"test": plugin})
102-
self.linter = Linter(config=config)
145+
self.linter = Linter(config)
103146
super().setUp()
104147

105148
def test_rules_are_ok(self):
@@ -110,7 +153,7 @@ def test_rules_are_ok(self):
110153
"data-var-dim-must-have-coord",
111154
"dataset-without-data-vars",
112155
],
113-
list(self.linter._config.plugins["test"].rules.keys()),
156+
list(self.linter.config.configs[0].plugins["test"].rules.keys()),
114157
)
115158

116159
def test_linter_respects_rule_severity_error(self):
@@ -181,6 +224,23 @@ def test_linter_respects_rule_severity_off(self):
181224
result,
182225
)
183226

227+
def test_linter_recognized_unknown_rule(self):
228+
result = self.linter.verify_dataset(
229+
xr.Dataset(), rules={"test/dataset-is-fast": 2}
230+
)
231+
self.assertEqual(
232+
[
233+
Message(
234+
message="unknown rule 'test/dataset-is-fast'",
235+
rule_id="test/dataset-is-fast",
236+
node_path=NODE_ROOT_NAME,
237+
severity=2,
238+
fatal=True,
239+
)
240+
],
241+
result.messages,
242+
)
243+
184244
def test_linter_real_life_scenario(self):
185245
dataset = xr.Dataset(
186246
attrs={
@@ -208,11 +268,13 @@ def test_linter_real_life_scenario(self):
208268

209269
result = self.linter.verify_dataset(
210270
dataset,
211-
rules={
212-
"test/no-space-in-attr-name": "error",
213-
"test/no-empty-attrs": "warn",
214-
"test/data-var-dim-must-have-coord": "error",
215-
"test/dataset-without-data-vars": "warn",
271+
config={
272+
"rules": {
273+
"test/no-space-in-attr-name": "error",
274+
"test/no-empty-attrs": "warn",
275+
"test/data-var-dim-must-have-coord": "error",
276+
"test/dataset-without-data-vars": "warn",
277+
},
216278
},
217279
)
218280
self.assertEqual(
@@ -262,15 +324,13 @@ def test_linter_real_life_scenario(self):
262324
result,
263325
)
264326

265-
def test_processor(self):
327+
def test_processor_ok(self):
266328
result = self.linter.verify_dataset(
267329
"test.levels",
268-
config=Config.from_value(
269-
{
270-
"processor": "test/multi-level-dataset",
271-
"rules": {"test/dataset-without-data-vars": "warn"},
272-
}
273-
),
330+
config={
331+
"processor": "test/multi-level-dataset",
332+
"rules": {"test/dataset-without-data-vars": "warn"},
333+
},
274334
)
275335

276336
self.assertEqual(
@@ -290,3 +350,24 @@ def test_processor(self):
290350
],
291351
result.messages,
292352
)
353+
354+
def test_processor_fail(self):
355+
result = self.linter.verify_dataset(
356+
"bad.levels",
357+
config={
358+
"processor": "test/multi-level-dataset",
359+
"rules": {"test/dataset-without-data-vars": "warn"},
360+
},
361+
)
362+
363+
self.assertEqual(
364+
[
365+
Message(
366+
message="bad checksum",
367+
severity=2,
368+
fatal=True,
369+
node_path=NODE_ROOT_NAME,
370+
)
371+
],
372+
result.messages,
373+
)

xrlint/_linter/apply.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from xrlint.rule import RuleConfig, RuleExit, RuleOp
33

44
from .rulectx import RuleContextImpl
5+
from ..constants import NODE_ROOT_NAME
56

67

78
def apply_rule(
@@ -33,9 +34,9 @@ def apply_rule(
3334
DatasetNode(
3435
parent=None,
3536
path=(
36-
"dataset"
37+
NODE_ROOT_NAME
3738
if context.file_index is None
38-
else f"dataset[{context.file_index}]"
39+
else f"{NODE_ROOT_NAME}[{context.file_index}]"
3940
),
4041
dataset=context.dataset,
4142
),

xrlint/_linter/rulectx.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import xarray as xr
55

66
from xrlint.config import Config
7-
from xrlint.constants import SEVERITY_ERROR
7+
from xrlint.constants import SEVERITY_ERROR, NODE_ROOT_NAME
88
from xrlint.node import Node
99
from xrlint.result import Message, Suggestion
1010
from xrlint.rule import RuleContext
@@ -66,7 +66,7 @@ def report(
6666
fatal=fatal,
6767
suggestions=suggestions,
6868
rule_id=self.rule_id,
69-
node_path=self.node.path if self.node is not None else None,
69+
node_path=self.node.path if self.node is not None else NODE_ROOT_NAME,
7070
severity=self.severity,
7171
)
7272
self.messages.append(m)

0 commit comments

Comments
 (0)