diff --git a/CHANGES.md b/CHANGES.md index 762db7e..0664596 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,11 @@ # XRLint Change History +## Version 0.5.2 (in development) + +- Fixed propagation of global DataTree attributes to child + Datasets and Variables. (#63) + + ## Version 0.5.1 (from 2025-02-21) - XRLint now also loads default configuration from files named diff --git a/tests/test_linter.py b/tests/test_linter.py index c8d650c..e190165 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( @@ -308,6 +314,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,