Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
46 changes: 46 additions & 0 deletions tests/test_linter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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={
Expand Down
38 changes: 38 additions & 0 deletions tests/util/test_attrs.py
Original file line number Diff line number Diff line change
@@ -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"]
9 changes: 7 additions & 2 deletions xrlint/plugins/core/rules/content_desc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -105,7 +109,8 @@ def validate_variable(self, ctx: RuleContext, node: VariableNode):
return

var_attrs = node.array.attrs
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}.")
9 changes: 7 additions & 2 deletions xrlint/plugins/core/rules/conventions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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"
Expand All @@ -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):
Expand Down
43 changes: 43 additions & 0 deletions xrlint/util/attrs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# 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


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.parent
while 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)
Loading