From c84f97f86f2bdb7455cbd2025614aa1369bfb332 Mon Sep 17 00:00:00 2001 From: clarasb Date: Thu, 8 Jan 2026 15:16:44 +0100 Subject: [PATCH 1/5] fix propagation of global attributes to child datasets and variables --- CHANGES.md | 6 ++++++ xrlint/_linter/apply.py | 22 ++++++++++++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) 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/xrlint/_linter/apply.py b/xrlint/_linter/apply.py index 8544717..803d56f 100644 --- a/xrlint/_linter/apply.py +++ b/xrlint/_linter/apply.py @@ -64,7 +64,18 @@ def apply_rule( def _visit_datatree_node(rule_op: RuleOp, context: RuleContextImpl, node: DataTreeNode): with context.use_state(node=node): rule_op.validate_datatree(context, node) + + current_attrs = node.datatree.attrs.copy() + if node.datatree.is_leaf: + dataset_copy = node.datatree.dataset.copy() + merged_dataset_attrs = { + **current_attrs, + **dataset_copy.attrs, + } + + dataset_copy.attrs = merged_dataset_attrs + _visit_dataset_node( rule_op, context, @@ -72,11 +83,18 @@ 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_copy, ), ) else: for name, datatree in node.datatree.children.items(): + datatree_copy = datatree.copy() + + datatree_copy.attrs = { + **current_attrs, + **datatree_copy.attrs, + } + _visit_datatree_node( rule_op, context, @@ -84,7 +102,7 @@ def _visit_datatree_node(rule_op: RuleOp, context: RuleContextImpl, node: DataTr parent=node, path=f"{node.path}/{name}", name=name, - datatree=datatree, + datatree=datatree_copy, ), ) From b05b757289aadb398ac292675954e700a31e88a3 Mon Sep 17 00:00:00 2001 From: clarasb Date: Mon, 26 Jan 2026 18:30:06 +0100 Subject: [PATCH 2/5] adjust attrs handling and add comments --- tests/test_linter.py | 109 ++++++++++++++++++++++++++++++++++++++++ xrlint/_linter/apply.py | 32 +++++------- 2 files changed, 122 insertions(+), 19 deletions(-) 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 803d56f..c194243 100644 --- a/xrlint/_linter/apply.py +++ b/xrlint/_linter/apply.py @@ -62,20 +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) - current_attrs = node.datatree.attrs.copy() - if node.datatree.is_leaf: - dataset_copy = node.datatree.dataset.copy() - merged_dataset_attrs = { - **current_attrs, - **dataset_copy.attrs, - } - - dataset_copy.attrs = merged_dataset_attrs - + # 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, @@ -83,18 +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=dataset_copy, + dataset=dataset, ), ) else: for name, datatree in node.datatree.children.items(): - datatree_copy = datatree.copy() - - datatree_copy.attrs = { - **current_attrs, - **datatree_copy.attrs, - } - + # Inherit attrs from the parent datatree into the child datatree + datatree = datatree.copy() + datatree.attrs = {**attrs, **datatree.attrs} _visit_datatree_node( rule_op, context, @@ -102,7 +96,7 @@ def _visit_datatree_node(rule_op: RuleOp, context: RuleContextImpl, node: DataTr parent=node, path=f"{node.path}/{name}", name=name, - datatree=datatree_copy, + datatree=datatree, ), ) From 3f66e9a2766575e32fe7341b3027e8cf9294597d Mon Sep 17 00:00:00 2001 From: clarasb Date: Tue, 27 Jan 2026 09:51:47 +0100 Subject: [PATCH 3/5] add test to test_rules_are_ok --- tests/test_linter.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_linter.py b/tests/test_linter.py index e190165..5c6ec5a 100644 --- a/tests/test_linter.py +++ b/tests/test_linter.py @@ -173,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()), ) From 224478eafeef8e72d43be8d851460e80f4eb3ca6 Mon Sep 17 00:00:00 2001 From: clarasb Date: Tue, 27 Jan 2026 10:14:06 +0100 Subject: [PATCH 4/5] update CHANGES.md --- CHANGES.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index f7cbf91..2c09465 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -22,8 +22,8 @@ - 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 propagation of global DataTree attributes to child - Datasets and Variables. (#63) +- 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) From e27d640db256100c72fced43686f4110ae20cbaf Mon Sep 17 00:00:00 2001 From: clarasb Date: Wed, 28 Jan 2026 11:47:35 +0100 Subject: [PATCH 5/5] add isodate to environment.yml --- environment.yml | 1 + 1 file changed, 1 insertion(+) 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