From 4c08a7710cb12c0cb2b4fa88a26cf47d8d7dcf2b Mon Sep 17 00:00:00 2001 From: clarasb Date: Mon, 20 Jul 2026 16:50:25 +0200 Subject: [PATCH 1/4] add metadata lookup in parent DataTree attrs to core rules --- tests/test_linter.py | 46 +++++++++++++++++++++++ xrlint/plugins/core/rules/content_desc.py | 11 +++++- xrlint/plugins/core/rules/conventions.py | 9 ++++- xrlint/util/attrs.py | 44 ++++++++++++++++++++++ 4 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 xrlint/util/attrs.py diff --git a/tests/test_linter.py b/tests/test_linter.py index b63d3db..eecc27d 100644 --- a/tests/test_linter.py +++ b/tests/test_linter.py @@ -308,6 +308,52 @@ def test_linter_recognized_datatree_rule(self): self.assertEqual(5, result.error_count) self.assertEqual(0, result.fatal_error_count) + def test_linter_global_datatree_attrs_from_parent_group(self): + global_attrs = { + "Conventions": "CF-1.12", + "title": "My title", + "references": "DOI:1234", + "institution": "My institute", + "source": "My source", + "comment": "My comment", + "history": "My history", + } + temp = xr.DataArray( + [1, 2, 3], + dims="x", + attrs={ + "units": "K", + "long_name": "Air Temperature", + "standard_name": "air_temperature", + }, + ) + x = xr.DataArray([1, 2, 3], dims="x", attrs={"units": "m"}) + mygroup = xr.Dataset( + {"temp": temp}, coords={"x": x}, attrs={"title": "My group"} + ) + + result = new_linter( + rules={ + "content-desc": 2, + "conventions": 2, + }, + ).validate( + xr.DataTree.from_dict( + { + "/": xr.Dataset(attrs=global_attrs), + "/mygroup": mygroup, + } + ), + ) + + 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/plugins/core/rules/content_desc.py b/xrlint/plugins/core/rules/content_desc.py index 3d4152e..cf57728 100644 --- a/xrlint/plugins/core/rules/content_desc.py +++ b/xrlint/plugins/core/rules/content_desc.py @@ -6,6 +6,7 @@ from xrlint.node import DatasetNode, VariableNode from xrlint.plugins.core.plugin import plugin +from xrlint.util.attrs import hierarchical_attrs from xrlint.rule import RuleContext, RuleExit, RuleOp from xrlint.util.schema import schema @@ -24,6 +25,9 @@ "A dataset should provide information about where the data came" " from and what has been done to it." " This information is mainly for the benefit of human readers." + " For `xarray.DataTree` inputs, global and common attributes may" + " be defined on parent groups; local dataset attributes take" + " precedence over parent attributes." " The rule accepts the following configuration parameters:\n\n" "- `globals`: list of names of required global attributes." f" Defaults to `{DEFAULT_GLOBAL_ATTRS}`.\n" @@ -79,7 +83,7 @@ def __init__(self, **params): ] def validate_dataset(self, ctx: RuleContext, node: DatasetNode): - dataset_attrs = node.dataset.attrs + dataset_attrs = hierarchical_attrs(node) attr_names = ( self.global_attrs + self.common_attrs if self.skip_vars @@ -105,7 +109,10 @@ def validate_variable(self, ctx: RuleContext, node: VariableNode): return var_attrs = node.array.attrs - dataset_attrs = ctx.dataset.attrs + if isinstance(node.parent, DatasetNode): + dataset_attrs = hierarchical_attrs(node.parent) + else: + dataset_attrs = ctx.dataset.attrs for attr_name in self.common_attrs: if attr_name not in var_attrs and attr_name not in dataset_attrs: ctx.report(f"Missing attribute {attr_name!r}.") diff --git a/xrlint/plugins/core/rules/conventions.py b/xrlint/plugins/core/rules/conventions.py index f116047..0effdef 100644 --- a/xrlint/plugins/core/rules/conventions.py +++ b/xrlint/plugins/core/rules/conventions.py @@ -6,6 +6,7 @@ from xrlint.node import DatasetNode from xrlint.plugins.core.plugin import plugin +from xrlint.util.attrs import hierarchical_attrs from xrlint.rule import RuleContext, RuleExit, RuleOp from xrlint.util.schema import schema @@ -21,6 +22,9 @@ " is a regex pattern that the value of the `Conventions` attribute" " must match, if any. If not provided, the rule just verifies" " that the attribute exists and whether it is a character string." + " For `xarray.DataTree` inputs, the attribute may be defined on" + " a parent group; local dataset attributes take precedence over" + " parent attributes." ), docs_url=( "https://cfconventions.org/cf-conventions/cf-conventions.html" @@ -38,10 +42,11 @@ def __init__(self, match: str | None = None): self.match = re.compile(match) if match else None def validate_dataset(self, ctx: RuleContext, node: DatasetNode): - if "Conventions" not in node.dataset.attrs: + attrs = hierarchical_attrs(node) + if "Conventions" not in attrs: ctx.report("Missing attribute 'Conventions'.") else: - value = node.dataset.attrs.get("Conventions") + value = attrs.get("Conventions") if not isinstance(value, str) and value: ctx.report(f"Invalid attribute 'Conventions': {value!r}.") elif self.match is not None and not self.match.match(value): diff --git a/xrlint/util/attrs.py b/xrlint/util/attrs.py new file mode 100644 index 0000000..71c1d61 --- /dev/null +++ b/xrlint/util/attrs.py @@ -0,0 +1,44 @@ +# Copyright © 2025 Brockmann Consult GmbH. +# This software is distributed under the terms and conditions of the +# MIT license (https://mit-license.org/). + +from collections.abc import Iterator, Mapping +from typing import Any + +from xrlint.node import DataTreeNode, DatasetNode, Node + + +class HierarchicalAttrs(Mapping[str, Any]): + """Attribute lookup for metadata rules with DataTree parent context.""" + + def __init__(self, node: DatasetNode): + self._attrs_by_precedence = [node.dataset.attrs] + parent: Node | None = node.parent + while parent is not None: + if isinstance(parent, DataTreeNode): + self._attrs_by_precedence.append(parent.datatree.attrs) + parent = parent.parent + + def __contains__(self, key: object) -> bool: + return any(key in attrs for attrs in self._attrs_by_precedence) + + def __getitem__(self, key: str) -> Any: + for attrs in self._attrs_by_precedence: + if key in attrs: + return attrs[key] + raise KeyError(key) + + def __iter__(self) -> Iterator[str]: + seen: set[str] = set() + for attrs in self._attrs_by_precedence: + for key in attrs: + if key not in seen: + seen.add(key) + yield key + + def __len__(self) -> int: + return sum(1 for _ in self) + + +def hierarchical_attrs(node: DatasetNode) -> HierarchicalAttrs: + return HierarchicalAttrs(node) From e776c1dc0ede4364d6510a1c9b077fdb3bff1041 Mon Sep 17 00:00:00 2001 From: clarasb Date: Mon, 20 Jul 2026 17:16:11 +0200 Subject: [PATCH 2/4] add tests --- tests/util/test_attrs.py | 38 +++++++++++++++++++++++ xrlint/plugins/core/rules/content_desc.py | 6 ++-- 2 files changed, 40 insertions(+), 4 deletions(-) create mode 100644 tests/util/test_attrs.py diff --git a/tests/util/test_attrs.py b/tests/util/test_attrs.py new file mode 100644 index 0000000..5dc091e --- /dev/null +++ b/tests/util/test_attrs.py @@ -0,0 +1,38 @@ +# Copyright © 2025 Brockmann Consult GmbH. +# This software is distributed under the terms and conditions of the +# MIT license (https://mit-license.org/). + +from unittest import TestCase + +import xarray as xr + +from xrlint.node import DataTreeNode, DatasetNode +from xrlint.util.attrs import hierarchical_attrs + + +class HierarchicalAttrsTest(TestCase): + def test_hierarchical_attrs(self): + root_node = DataTreeNode( + parent=None, + path="dt", + name="dt", + datatree=xr.DataTree( + dataset=xr.Dataset(attrs={"title": "root", "history": "root"}) + ), + ) + dataset_node = DatasetNode( + parent=root_node, + path="dt/group", + name="group", + dataset=xr.Dataset(attrs={"title": "dataset"}), + ) + + attrs = hierarchical_attrs(dataset_node) + + self.assertEqual("dataset", attrs["title"]) + self.assertEqual("root", attrs["history"]) + self.assertEqual(["title", "history"], list(attrs)) + self.assertEqual(2, len(attrs)) + self.assertNotIn("comment", attrs) + with self.assertRaises(KeyError): + _ = attrs["comment"] diff --git a/xrlint/plugins/core/rules/content_desc.py b/xrlint/plugins/core/rules/content_desc.py index cf57728..513b218 100644 --- a/xrlint/plugins/core/rules/content_desc.py +++ b/xrlint/plugins/core/rules/content_desc.py @@ -109,10 +109,8 @@ def validate_variable(self, ctx: RuleContext, node: VariableNode): return var_attrs = node.array.attrs - if isinstance(node.parent, DatasetNode): - dataset_attrs = hierarchical_attrs(node.parent) - else: - dataset_attrs = ctx.dataset.attrs + assert isinstance(node.parent, DatasetNode) + dataset_attrs = hierarchical_attrs(node.parent) for attr_name in self.common_attrs: if attr_name not in var_attrs and attr_name not in dataset_attrs: ctx.report(f"Missing attribute {attr_name!r}.") From cc986b3db2713faf2a04b8450d162b6fa1914c97 Mon Sep 17 00:00:00 2001 From: clarasb Date: Mon, 20 Jul 2026 17:16:40 +0200 Subject: [PATCH 3/4] update CHANGES.md --- CHANGES.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 6e00ca1..509338b 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 metadata rules for datatrees: global/common attributes defined + on parent groups are now considered by the relevant core rules. (#63) + ## Version 0.5.1 (from 2025-02-21) From 67492c49d09cd2792165d137eb8a818730d71920 Mon Sep 17 00:00:00 2001 From: clarasb Date: Mon, 20 Jul 2026 17:47:32 +0200 Subject: [PATCH 4/4] adjust attrs.py and test_attrs.py --- tests/util/test_attrs.py | 2 +- xrlint/util/attrs.py | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/util/test_attrs.py b/tests/util/test_attrs.py index 5dc091e..950f251 100644 --- a/tests/util/test_attrs.py +++ b/tests/util/test_attrs.py @@ -35,4 +35,4 @@ def test_hierarchical_attrs(self): self.assertEqual(2, len(attrs)) self.assertNotIn("comment", attrs) with self.assertRaises(KeyError): - _ = attrs["comment"] + attrs["comment"] diff --git a/xrlint/util/attrs.py b/xrlint/util/attrs.py index 71c1d61..642d0de 100644 --- a/xrlint/util/attrs.py +++ b/xrlint/util/attrs.py @@ -5,7 +5,7 @@ from collections.abc import Iterator, Mapping from typing import Any -from xrlint.node import DataTreeNode, DatasetNode, Node +from xrlint.node import DataTreeNode, DatasetNode class HierarchicalAttrs(Mapping[str, Any]): @@ -13,10 +13,9 @@ class HierarchicalAttrs(Mapping[str, Any]): def __init__(self, node: DatasetNode): self._attrs_by_precedence = [node.dataset.attrs] - parent: Node | None = node.parent - while parent is not None: - if isinstance(parent, DataTreeNode): - self._attrs_by_precedence.append(parent.datatree.attrs) + parent = node.parent + while isinstance(parent, DataTreeNode): + self._attrs_by_precedence.append(parent.datatree.attrs) parent = parent.parent def __contains__(self, key: object) -> bool: