diff --git a/CHANGES.md b/CHANGES.md index 6e00ca1..2c09465 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -22,6 +22,9 @@ - Load plugins from entry points allowing plugins to be discovered from installed libraries. - Automatically generate rule documentation removing the manual need to run `mkruleref.py`. +- Fixed passing of global datatree attributes to children: attributes defined + on parent datatrees are now inherited by all descendants. (#63) + ## Version 0.5.1 (from 2025-02-21) diff --git a/environment.yml b/environment.yml index b39d4b4..9838800 100644 --- a/environment.yml +++ b/environment.yml @@ -6,6 +6,7 @@ dependencies: # Library Dependencies - click - fsspec + - isodate>=0.7.2 - pyyaml - tabulate - xarray diff --git a/tests/test_linter.py b/tests/test_linter.py index c8d650c..5c6ec5a 100644 --- a/tests/test_linter.py +++ b/tests/test_linter.py @@ -138,6 +138,12 @@ def validate_datatree(self, ctx: RuleContext, node: DataTreeNode): if len(node.datatree.data_vars) == 0: ctx.report("DataTree does not have data variables") + @plugin.define_rule("datatree-children-must-have-title") + class DataTreeAttrsVer(RuleOp): + def validate_datatree(self, ctx: RuleContext, node: DataTreeNode): + if "title" not in node.datatree.attrs: + ctx.report("DataTree must have a least a global title") + @plugin.define_processor("multi-level-dataset") class MultiLevelDataset(ProcessorOp): def preprocess( @@ -167,6 +173,7 @@ def test_rules_are_ok(self): "data-var-dim-must-have-coord", "dataset-without-data-vars", "datatree-without-data-vars", + "datatree-children-must-have-title", ], list(self.linter.config.objects[0].plugins["test"].rules.keys()), ) @@ -308,6 +315,109 @@ def test_linter_recognized_datatree_rule(self): self.assertEqual(5, result.error_count) self.assertEqual(0, result.fatal_error_count) + def test_linter_missing_global_datatree_attrs(self): + result = self.linter.validate( + xr.DataTree( + children={ + "measurement": xr.DataTree( + children={ + "r10m": xr.DataTree( + dataset=xr.Dataset( + attrs={ + "title": "10m resolution datatree", + } + ) + ), + "r20m": xr.DataTree(), + "r60m": xr.DataTree(), + } + ) + }, + ), + rules={"test/datatree-children-must-have-title": 2}, + ) + + self.assertEqual( + [ + Message( + message="DataTree must have a least a global title", + node_path="dt", + rule_id="test/datatree-children-must-have-title", + severity=2, + fatal=None, + fix=None, + suggestions=None, + ), + Message( + message="DataTree must have a least a global title", + node_path="dt/measurement", + rule_id="test/datatree-children-must-have-title", + severity=2, + fatal=None, + fix=None, + suggestions=None, + ), + Message( + message="DataTree must have a least a global title", + node_path="dt/measurement/r20m", + rule_id="test/datatree-children-must-have-title", + severity=2, + fatal=None, + fix=None, + suggestions=None, + ), + Message( + message="DataTree must have a least a global title", + node_path="dt/measurement/r60m", + rule_id="test/datatree-children-must-have-title", + severity=2, + fatal=None, + fix=None, + suggestions=None, + ), + ], + result.messages, + ) + self.assertEqual(0, result.warning_count) + self.assertEqual(4, result.error_count) + self.assertEqual(0, result.fatal_error_count) + + def test_linter_global_datatree_attrs(self): + result = self.linter.validate( + xr.DataTree( + dataset=xr.Dataset( + attrs={ + "title": "Global datatree title", + } + ), + children={ + "measurement": xr.DataTree( + children={ + "r10m": xr.DataTree( + dataset=xr.Dataset( + attrs={ + "title": "10m resolution datatree", + } + ) + ), + "r20m": xr.DataTree(), + "r60m": xr.DataTree(), + } + ) + }, + ), + rules={"test/datatree-children-must-have-title": 2}, + ) + + print(result.messages) + self.assertEqual( + [], + result.messages, + ) + self.assertEqual(0, result.warning_count) + self.assertEqual(0, result.error_count) + self.assertEqual(0, result.fatal_error_count) + def test_linter_real_life_scenario(self): dataset = xr.Dataset( attrs={ diff --git a/xrlint/_linter/apply.py b/xrlint/_linter/apply.py index 8544717..c194243 100644 --- a/xrlint/_linter/apply.py +++ b/xrlint/_linter/apply.py @@ -62,9 +62,18 @@ def apply_rule( def _visit_datatree_node(rule_op: RuleOp, context: RuleContextImpl, node: DataTreeNode): + # Get a copy of the current node's attrs. + # These will be merged into each child's attrs so that attributes + # defined on parent nodes are inherited by all descendants. + attrs = node.datatree.attrs.copy() + with context.use_state(node=node): rule_op.validate_datatree(context, node) + if node.datatree.is_leaf: + # Inherit attrs from the parent datatree into the child dataset + dataset = node.datatree.dataset.copy() + dataset.attrs = {**attrs, **dataset.attrs} _visit_dataset_node( rule_op, context, @@ -72,11 +81,14 @@ def _visit_datatree_node(rule_op: RuleOp, context: RuleContextImpl, node: DataTr parent=node, path=f"{node.path}/{node.datatree.name}", name=node.datatree.name, - dataset=node.datatree.dataset, + dataset=dataset, ), ) else: for name, datatree in node.datatree.children.items(): + # Inherit attrs from the parent datatree into the child datatree + datatree = datatree.copy() + datatree.attrs = {**attrs, **datatree.attrs} _visit_datatree_node( rule_op, context,