Repository: ecmwf/earthkit-data
Pull request #510 (2024-11-05 13:15:43)

Add callable attributes in xarray engine

codecov-commenter: ## [Codecov](https://app.codecov.io/gh/ecmwf/earthkit-data/pull/510?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ecmwf) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 91.17%. Comparing base [(`391d5fb`)](https://app.codecov.io/gh/ecmwf/earthkit-data/commit/391d5fbd352fd2feaee59f3dfd3776aed858814a?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ecmwf) to head [(`ae11a29`)](https://app.codecov.io/gh/ecmwf/earthkit-data/commit/ae11a299fdbb59917c3a59f6828f6220e04df9a1?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ecmwf).

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff             @@
##           develop     #510      +/-   ##
===========================================
+ Coverage    91.16%   91.17%   +0.01%     
===========================================
  Files          147      147              
  Lines        10591    10606      +15     
  Branches       505      506       +1     
===========================================
+ Hits          9655     9670      +15     
  Misses         773      773              
  Partials       163      163              
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/ecmwf/earthkit-data/pull/510?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ecmwf).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ecmwf).


Patch:
diff --git a/docs/conf.py b/docs/conf.py
index 4594f89..121223f 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -136,6 +136,6 @@ intersphinx_mapping = {
 
 
 def setup(app):
-    pass
+    from skip_api_rules import _skip_api_items
 
-    # app.connect("autoapi-skip-member", _skip_api_items)
+    app.connect("autoapi-skip-member", _skip_api_items)
diff --git a/docs/index.rst b/docs/index.rst
index e46bf25..1860f08 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -38,7 +38,6 @@ reference systems and bounding boxes.
    guide/index
    api
    development
-   _api/data/index
 
 .. toctree::
    :maxdepth: 1
diff --git a/src/earthkit/data/core/order.py b/src/earthkit/data/core/order.py
index 50a144f..ccf5e31 100644
--- a/src/earthkit/data/core/order.py
+++ b/src/earthkit/data/core/order.py
@@ -15,7 +15,6 @@ LOG = logging.getLogger(__name__)
 
 class Remapping(dict):
     # inherit from dict to make it serialisable
-
     def __init__(self, remapping):
         super().__init__(remapping)
 
@@ -99,7 +98,6 @@ def _build_remapping(mapping):
 
 class Patch(dict):
     # inherit from dict to make it serialisable
-
     def __init__(self, proc, name, patch):
         self.proc = proc
         self.name = name
diff --git a/src/earthkit/data/readers/grib/xarray.py b/src/earthkit/data/readers/grib/xarray.py
index 1ccc595..df34afc 100644
--- a/src/earthkit/data/readers/grib/xarray.py
+++ b/src/earthkit/data/readers/grib/xarray.py
@@ -75,7 +75,7 @@ class XarrayMixIn:
 
     def to_xarray(self, engine="earthkit", xarray_open_dataset_kwargs=None, **kwargs):
         """
-        Convert the FieldList into an Xarray DataSet.
+        Convert the FieldList into an Xarray Dataset.
 
         Parameters
         ----------
@@ -112,7 +112,7 @@ class XarrayMixIn:
             * rename_variables: dict, None
                 Mapping to rename variables. Default is None.
             * extra_dims: str, or iterable of str, None
-                Metadata key or list of metadata keys to use as additional dimensions on top of the
+                Metadata key or list of metadata keys to be used as additional dimensions on top of the
                 predefined dimensions. Only enabled when no ``fixed_dims`` is specified. Default is None.
             * drop_dims:  str, or iterable of str, None
                 Metadata key or list of metadata keys to be ignored as dimensions. Default is None.
@@ -141,9 +141,9 @@ class XarrayMixIn:
                 - "time": metadata key interpreted as time part of the "forecast_reference_time"
                 - "step": metadata key interpreted as forecast step
                 - "forecast_reference_time": if not specified or None or empty the forecast reference
-                time is built using the "date" and "time" roles
+                  time is built using the "date" and "time" roles
                 - "valid_time": if not specified or None or empty the valid time is built using the
-                "validityDate" and "validityTime" metadata keys
+                  "validityDate" and "validityTime" metadata keys
                 - "level": metadata key interpreted as level
                 - "level_type": metadata key interpreted as level type
 
@@ -176,12 +176,12 @@ class XarrayMixIn:
 
                 - "forecast": adds two dimensions:
 
-                - "forecast_reference_time": built from the "date" and "time" roles
+                  - "forecast_reference_time": built from the "date" and "time" roles
                     (see ``dim_roles``) as np.datetime64 values
-                - "step": built from the "step" role. When ``decode_time=True`` the values are
+                  - "step": built from the "step" role. When ``decode_time=True`` the values are
                     np.timedelta64
                 - "valid_time": adds a dimension called "valid_time" as described by the "valid_time"
-                role (see ``dim_roles``). Will contain np.datetime64 values,
+                  role (see ``dim_roles``). Will contain np.datetime64 values.
                 - "raw": the "date", "time" and "step" roles are turned into 3 separate dimensions
             * level_dim_mode: str, None
                 Define how predefined vertical dimensions are formed. The default is "level".
@@ -189,7 +189,7 @@ class XarrayMixIn:
 
                 - "level": adds a single dimension according to the "level" role (see ``dim_roles``)
                 - "level_per_type": adds a separate dimensions for each level type based on the
-                "level" and "level_type" roles.
+                  "level" and "level_type" roles.
                 - "level_and_type": Use a single dimension for combined level and type of level.
             * squeeze: bool, None
                 Remove dimensions which have only one valid value. Not applies to dimensions in
@@ -227,18 +227,29 @@ class XarrayMixIn:
                 Define how attributes are generated. Default is "fixed". The possible values are:
 
                 - "fixed": Use the attributes defined in ``variable_attrs`` as variables
-                attributes and ``global_attrs`` as global attributes.
+                  attributes and ``global_attrs`` as global attributes.
                 - "unique": Use all the attributes defined in ``attrs``, ``variable_attrs``
-                and ``global_attrs``. When an attribute has unique a value for a dataset
-                it will be a global attribute, otherwise it will be a variable attribute.
-                However keys in ``variable_attrs`` are always used as variable attributes,
-                while keys in ``global_attrs`` are always used as global attributes.
-            * attrs: str or list, None
-                List of metadata keys to use as attributes.
-            * variable_attrs: str or list, None
-                Metadata key or keys to use as variable attributes. Default is None.
-            * global_attrs: , None
-                Metadata key or keys to use as global attributes. Default is None.
+                  and ``global_attrs``. When an attribute has unique a value for a dataset
+                  it will be a global attribute, otherwise it will be a variable attribute.
+                  However keys in ``variable_attrs`` are always used as variable attributes,
+                  while keys in ``global_attrs`` are always used as global attributes.
+            * attrs: str, number, callable, dict or list of these, None
+                Attribute or list of attributes. Only used when ``attrs_mode`` is ``unique``.
+                Its default value (None) expands to [] unless the ``profile`` overwrites it.
+                The following attributes are supported:
+
+                - str: Name of the attribute used as a metadata key to generate the value of the attribute
+                - callable: A callable that takes a Metadata object and returns a dict of attributes
+                - dict: A dictionary of attributes with the keys as the attribute names.
+                  If the value is a callable it takes the attribute name and a Metadata object and
+                  returns the value of the attribute. Otherwise the value is interpreted as a
+                  pre-defined value for the attribute.
+            * variable_attrs: str, number, callable, dict or list of these, None
+                Variable attribute or attributes. For the allowed values see ``attrs``. Its
+                default value (None) expands to [] unless the ``profile`` overwrites it.
+            * global_attrs: str, number, dict or list of these, None
+                Global attribute or attributes. For the allowed values see ``attrs``. Its
+                default value (None) expands to [] unless the ``profile`` overwrites it.
             * coord_attrs: dict, None
                 To be documented. Default is None.
             * rename_attrs: dict, None
@@ -246,15 +257,15 @@ class XarrayMixIn:
             * remapping: dict, None
                 Define new metadata keys for indexing. Default is None.
             * lazy_load: bool, None
-                If True, the resulting DataSet will load data lazily from the
-                underlying data source. If False, a DataSet holding all the data in memory
+                If True, the resulting Dataset will load data lazily from the
+                underlying data source. If False, a Dataset holding all the data in memory
                 and decoupled from the backend source will be created.
                 Using ``lazy_load=False`` with ``release_source=True`` can provide optimised
                 memory usage in certain cases. The default value of ``lazy_load`` (None)
                 expands to True unless the ``profile`` overwrites it.
             * release_source: bool, None
                 Only used when ``lazy_load=False``. If True, memory held in the input fields are
-                released as soon as their values are copied into the resulting DataSet. This is
+                released as soon as their values are copied into the resulting Dataset. This is
                 done per field to avoid memory spikes. The release operation is currently
                 only supported for GRIB fields stored entirely in memory, e.g. when read from a
                 :ref:`stream <streams>`. When a field does not support the release operation, this
@@ -286,7 +297,7 @@ class XarrayMixIn:
 
         Returns
         -------
-        xarray DataSet or list of xarray DataSets
+        xarray Dataset or list of xarray Datasets
 
 
         The default values of ``xarray_open_dataset_kwargs`` or ``**kwargs`` passed
diff --git a/src/earthkit/data/utils/xarray/attrs.py b/src/earthkit/data/utils/xarray/attrs.py
index 5f6c7c6..d083015 100644
--- a/src/earthkit/data/utils/xarray/attrs.py
+++ b/src/earthkit/data/utils/xarray/attrs.py
@@ -18,31 +18,137 @@ from earthkit.data.utils import ensure_iterable
 LOG = logging.getLogger(__name__)
 
 
-class GlobalAttrs:
+class Attr:
+    """Generic attribute class.
+
+    Parameters
+    ----------
+    name : str
+        The name of the attribute. Used as a metadata key to
+        retrieve the attribute value.
+    """
+
+    def __init__(self, name):
+        self.name = name
+
+    def value(self):
+        return None
+
+    def get(self, metadata):
+        return {self.name: metadata.get(self.name, default=None)}
+
+    def fixed(self):
+        False
+
+    def __repr__(self) -> str:
+        return f"Attr({self.name})"
+
+
+class FixedAttr(Attr):
+    """Fixed attribute.
+
+    Parameters
+    ----------
+    name : str
+        The name of the attribute.
+    value : str, bool, int, float
+        The value of the attribute.
+    """
+
+    def __init__(self, name, value):
+        super().__init__(name)
+        self._value = value
+
+    def value(self):
+        return self._value
+
+    def fixed(self):
+        return True
+
+    def __repr__(self) -> str:
+        return f"FixedAttr({self.name}, {self._value})"
+
+
+class NamespaceAttr(Attr):
+    """Namespace attribute.
+
+    Parameters
+    ----------
+    name : str
+        The name of the metadata namespace
+    """
+
+    def get(self, metadata):
+        return metadata.as_namespace(self.name)
+
+    def __repr__(self) -> str:
+        return f"NamespaceAttr({self.name})"
+
+
+class CallableAttr(Attr):
+    """Callable attribute.
+
+    Parameters
+    ----------
+    name : str, None
+        The name of the attribute. Can be None.
+    func : callable
+        The function that returns the value of the attribute.
+    """
+
+    def __init__(self, name, func):
+        super().__init__(name)
+        self.func = func
+
+    def __call__(self, metadata):
+        if self.name is None:
+            return self.func(metadata)
+        else:
+            res = self.func(self.name, metadata)
+            return {self.name: res}
+
+    def __repr__(self) -> str:
+        return f"CallableAttr({self.name})"
+
+
+class AttrList(list):
     def __init__(self, attrs):
-        self.fixed = {}
-        self.keys = []
         attrs = ensure_iterable(attrs)
         for k in attrs:
             if isinstance(k, str):
-                self.keys.append(k)
+                if k.startswith("namespace="):
+                    self.append(NamespaceAttr(k[len("namespace=") :]))
+                else:
+                    self.append(Attr(k))
+            elif callable(k):
+                self.append(CallableAttr(None, k))
             elif isinstance(k, dict):
                 for k1, v1 in k.items():
-                    self.fixed[k1] = v1
+                    if callable(v1):
+                        self.append(CallableAttr(k1, v1))
+                    else:
+                        self.append(FixedAttr(k1, v1))
             else:
-                raise ValueError(f"Invalid global attribute: {k}. Must be a str or a dict.")
+                raise ValueError(f"Invalid  attribute: {k}. Must be a str, callable or dict.")
+
+    def fixed(self):
+        return [attr for attr in self if attr.fixed()]
+
+    def non_fixed(self):
+        return [attr for attr in self if not attr.fixed()]
 
 
 class Attrs:
     def __init__(self, profile, attrs_mode, attrs, variable_attrs, global_attrs, coord_attrs, rename_attrs):
         self.profile = profile
-        self.attrs = ensure_iterable(attrs)
-        self.variable_attrs = ensure_iterable(variable_attrs)
-        self.global_attrs = GlobalAttrs(global_attrs)
+        self.attrs = AttrList(attrs)
+        self.variable_attrs = AttrList(variable_attrs)
+        self.global_attrs = AttrList(global_attrs)
         self.coord_attrs = ensure_dict(coord_attrs)
         self.rename_attrs_map = ensure_dict(rename_attrs)
 
-        for k in self.variable_attrs + self.global_attrs.keys:
+        # attrs must be unique and contain all variable and global attrs
+        for k in self.variable_attrs + self.global_attrs:
             if k not in self.attrs:
                 self.attrs.append(k)
 
@@ -82,9 +188,9 @@ class AttrsBuilder(metaclass=ABCMeta):
         global_attrs = rename(self._build(*args, rename=rename, **kwargs))
 
         # add fixed global attrs
-        for k, v in self.attrs.global_attrs.fixed.items():
-            if k not in global_attrs:
-                global_attrs[k] = v
+        for item in self.attrs.global_attrs.fixed():
+            if item.name not in global_attrs:
+                global_attrs[item.name] = item.value()
 
         # TODO: make it optional
         global_attrs.pop("units", None)
@@ -97,7 +203,9 @@ class UniqueAttrBuilder(AttrsBuilder):
         attrs = defaultdict(set)
 
         for var_obj in t_vars.values():
-            attrs.update(var_obj.load_attrs(self.attrs.attrs, strict=self.profile.strict))
+            s, _ = var_obj.collect_attrs(self.attrs.attrs, strict=self.profile.strict)
+            for k, v in s.items():
+                attrs[k].update(ensure_iterable(v))
 
         global_attrs = defaultdict(list)
         for k, v in attrs.items():
@@ -120,15 +228,16 @@ class FixedAttrBuilder(AttrsBuilder):
     def _build(self, ds, t_vars, rename=None):
         global_attrs = dict()
 
-        for var_obj in t_vars.values():
-            var_obj.load_attrs(self.attrs.variable_attrs, strict=self.profile.strict)
-            var_obj.adjust_attrs(rename=rename)
+        for i, var_obj in enumerate(t_vars.values()):
+            if i == 0:
+                _, global_attrs = var_obj.collect_attrs(
+                    self.attrs.variable_attrs,
+                    strict=self.profile.strict,
+                    extra_attrs=self.attrs.global_attrs.non_fixed(),
+                )
+            else:
+                var_obj.collect_attrs(self.attrs.variable_attrs, strict=self.profile.strict)
 
-        global_attrs = dict()
-        first = ds[0]
-        for k in self.attrs.global_attrs.keys:
-            v = first.metadata(k, default=None)
-            if v is not None:
-                global_attrs[k] = v
+            var_obj.adjust_attrs(rename=rename)
 
         return global_attrs
diff --git a/src/earthkit/data/utils/xarray/builder.py b/src/earthkit/data/utils/xarray/builder.py
index f573d0a..342fd91 100644
--- a/src/earthkit/data/utils/xarray/builder.py
+++ b/src/earthkit/data/utils/xarray/builder.py
@@ -18,6 +18,7 @@ import xarray.core.indexing as indexing
 from earthkit.data.utils import ensure_dict
 from earthkit.data.utils import ensure_iterable
 
+from .attrs import AttrList
 from .profile import Profile
 
 LOG = logging.getLogger(__name__)
@@ -33,6 +34,28 @@ class VariableBuilder:
     def __init__(
         self, name, var_dims, data_maker, tensor, remapping, local_attr_keys=None, fixed_local_attrs=None
     ):
+        """
+        Create a builder for a single variable in the dataset.
+
+        Parameters
+        ----------
+        name: str
+            The name of the variable.
+        var_dims: list
+            The dimensions of the variable.
+        data_maker: callable
+            A function that generates the data object stored in the xarray variable.
+        tensor: Tensor
+            The tensor object that contains the data.
+        remapping: Remapping
+            The remapping object that contains the remapping information.
+        local_attr_keys: list, optional
+            A list of metadata keys that are used to collect local attributes. These attributes
+            are not taken into account by the attribute policies.
+        fixed_local_attrs: dict, optional
+            A dictionary of fixed local attributes that are added to the variable. These attributes
+            are not taken into account by the attribute policies.
+        """
         self.name = name
         self.var_dims = var_dims
         self.data_maker = data_maker
@@ -51,42 +74,80 @@ class VariableBuilder:
         data = self.data_maker(self.tensor, self.var_dims, self.name)
         return xarray.Variable(self.var_dims, data, attrs=self._attrs)
 
-    def load_attrs(self, keys, strict=True):
-        keys = keys + self.local_keys
-        attr_keys = []
-        attrs = {}
-        ns_keys = []
-        for k in keys:
-            if k not in self.var_dims and k not in attr_keys and k not in self.fixed_local_attrs:
-                if k.startswith("namespace="):
-                    ns_keys.append(k.split("=")[1])
-                else:
-                    attr_keys.append(k)
+    def collect_attrs(self, attrs, strict=True, extra_attrs=None):
+        """Load the attributes for the variable.
+
+        Parameters
+        ----------
+        attrs: list
+            A list of attributes to be collected.
+        strict: bool, optional
+            If True, perform a strict check on the attributes.
+        collect: list, optional
+            A list of attributes that are collected but not added to the variable attributes.
+        """
+        attrs = attrs + AttrList(self.local_keys)
 
-        first = None
+        res = {}
+        keys_strict = []
+        fixed_attrs = {}
 
-        # TODO: do we need a strict mode here? The extra cost has to be justified
-        if strict:
-            attrs = self.tensor.source.unique_values(attr_keys)
-        else:
-            first = self.tensor.source[0]
-            attrs = {k: [v] for k, v in first._attributes(attr_keys, default=None).items()}
+        first = None
 
-        if ns_keys:
-            if not first:
-                first = self.tensor.source[0]
-            r = first.metadata(namespace=ns_keys)
-            for k, v in r.items():
-                attrs[k] = [v]
+        def _metadata():
+            nonlocal first
+            return self.tensor.source[0].metadata() if not first else first
 
-        self._attrs = attrs
+        for a in attrs:
+            if a.name not in self.var_dims and a.name not in res and a.name not in self.fixed_local_attrs:
+                if a.fixed():
+                    fixed_attrs[a.name] = a.value()
+                elif callable(a):
+                    res.update(a(_metadata()))
+                else:
+                    if strict:
+                        keys_strict.append(a.name)
+                    else:
+                        res.update(a.get(_metadata()))
 
-        return {k: v for k, v in self._attrs.items() if k not in self.local_keys}
+        # TODO: do we need a strict mode here? The extra cost has to be justified
+        if keys_strict:
+            assert strict
+            res.update(self.tensor.source.unique_values(keys_strict))
+
+        self._attrs = res
+        self._attrs.update(fixed_attrs)
+
+        # extra attributes to be collected but not added to the variable
+        collected_attrs = {}
+        if extra_attrs:
+            res = {}
+            fixed_attrs = {}
+            for a in extra_attrs:
+                if a.fixed():
+                    fixed_attrs[a.name] = a.value()
+                elif callable(a):
+                    res.update(a(_metadata()))
+                elif isinstance(a, str):
+                    res.update(a.get(_metadata()))
+
+            collected_attrs.update(res)
+            collected_attrs.update(fixed_attrs)
+
+        return {k: v for k, v in self._attrs.items() if k not in self.local_keys}, collected_attrs
 
     def adjust_attrs(self, drop_keys=None, rename=None):
         drop_keys = ensure_iterable(drop_keys)
         drop_keys = [k for k in drop_keys if k not in self.local_keys]
-        self._attrs = {k: v[0] for k, v in self._attrs.items() if k not in drop_keys and len(v) == 1}
+        r = {}
+        for k, v in self._attrs.items():
+            if k not in drop_keys:
+                if isinstance(v, list) and len(v) == 1:
+                    r[k] = v[0]
+                else:
+                    r[k] = v
+        self._attrs = r
+        # self._attrs = {k: v[0] for k, v in self._attrs.items() if k not in drop_keys and len(v) == 1}
         if callable(rename):
             self._attrs = rename(self._attrs)
 
diff --git a/src/earthkit/data/utils/xarray/engine.py b/src/earthkit/data/utils/xarray/engine.py
index 05df563..2bc152b 100644
--- a/src/earthkit/data/utils/xarray/engine.py
+++ b/src/earthkit/data/utils/xarray/engine.py
@@ -70,7 +70,7 @@ class EarthkitBackendEntrypoint(BackendEntrypoint):
         rename_variables: dict, None
             Mapping to rename variables. Default is None.
         extra_dims:  str, or iterable of str, None
-            Metadata key or list of metadata keys to use as additional dimensions on top of the
+            Metadata key or list of metadata keys to be used as additional dimensions on top of the
             predefined dimensions. Only enabled when no ``fixed_dims`` is specified. Default is None.
         drop_dims:  str, or iterable of str, None
             Metadata key or list of metadata keys to be ignored as dimensions. Default is None.
@@ -139,7 +139,7 @@ class EarthkitBackendEntrypoint(BackendEntrypoint):
               - "step": built from the "step" role. When ``decode_time=True`` the values are
                 np.timedelta64
             - "valid_time": adds a dimension called "valid_time" as described by the "valid_time"
-              role (see ``dim_roles``). Will contain np.datetime64 values,
+              role (see ``dim_roles``). Will contain np.datetime64 values.
             - "raw": the "date", "time" and "step" roles are turned into 3 separate dimensions
         level_dim_mode: str, None
             Define how predefined vertical dimensions are formed. The default is "level".
@@ -191,12 +191,23 @@ class EarthkitBackendEntrypoint(BackendEntrypoint):
               it will be a global attribute, otherwise it will be a variable attribute.
               However keys in ``variable_attrs`` are always used as variable attributes,
               while keys in ``global_attrs`` are always used as global attributes.
-        attrs: str or list, None
-            List of metadata keys to use as attributes.
-        variable_attrs: str or list, None
-            Metadata key or keys to use as variable attributes. Default is None.
-        global_attrs: , None
-            Metadata key or keys to use as global attributes. Default is None.
+        attrs: str, number, callable, dict or list of these, None
+            Attribute or list of attributes. Only used when ``attrs_mode`` is ``unique``.
+            Its default value (None) expands to [] unless the ``profile`` overwrites it.
+            The following attributes are supported:
+
+            - str: Name of the attribute used as a metadata key to generate the value of the attribute
+            - callable: A callable that takes a Metadata object and returns a dict of attributes
+            - dict: A dictionary of attributes with the keys as the attribute names.
+              If the value is a callable it takes the attribute name and a Metadata object and
+              returns the value of the attribute. Otherwise the value is interpreted as a
+              pre-defined value for the attribute.
+        variable_attrs: str, number, callable, dict or list of these, None
+            Variable attribute or attributes. For the allowed values see ``attrs``. Its
+            default value (None) expands to [] unless the ``profile`` overwrites it.
+        global_attrs: str, number, dict or list of these, None
+            Global attribute or attributes. For the allowed values see ``attrs``. Its
+            default value (None) expands to [] unless the ``profile`` overwrites it.
         coord_attrs: dict, None
             To be documented. Default is None.
         rename_attrs: dict, None
@@ -204,7 +215,7 @@ class EarthkitBackendEntrypoint(BackendEntrypoint):
         remapping: dict, None
             Define new metadata keys for indexing. Default is None.
         lazy_load: bool, None
-            If True, the resulting DataSet will load data lazily from the
+            If True, the resulting Dataset will load data lazily from the
             underlying data source. If False, a DataSet holding all the data in memory
             and decoupled from the backend source will be created.
             Using ``lazy_load=False`` with ``release_source=True`` can provide optimised
@@ -212,7 +223,7 @@ class EarthkitBackendEntrypoint(BackendEntrypoint):
             expands to True unless the ``profile`` overwrites it.
         release_source: bool, None
             Only used when ``lazy_load=False``. If True, memory held in the input fields are
-            released as soon as their values are copied into the resulting DataSet. This is
+            released as soon as their values are copied into the resulting Dataset. This is
             done per field to avoid memory spikes. The release operation is currently
             only supported for GRIB fields stored entirely in memory, e.g. when read from a
             :ref:`stream <streams>`. When a field does not support the release operation, this


Test patch:
diff --git a/tests/xr_engine/test_xr_attrs.py b/tests/xr_engine/test_xr_attrs.py
index 896cb1f..18dc611 100644
--- a/tests/xr_engine/test_xr_attrs.py
+++ b/tests/xr_engine/test_xr_attrs.py
@@ -23,16 +23,66 @@ from xr_engine_fixtures import compare_coords  # noqa: E402
 from xr_engine_fixtures import compare_dims  # noqa: E402
 
 
+def _get_attrs(metadata):
+    return {k: metadata.get(k, None) for k in ["gridType", "units"]}
+
+
+def _get_attrs_for_key_1(key, metadata):
+    return str(metadata.get("units", "")) + "_test1"
+
+
+def _get_attrs_for_key_2(key, metadata):
+    return metadata.get(key, "") + "_test2"
+
+
 @pytest.mark.cache
 @pytest.mark.parametrize(
     "kwargs,coords,dims,attrs",
     [
         (
             {
+                "profile": "mars",
+                "dims_as_attrs": None,
+                "time_dim_mode": "raw",
+                "decode_times": False,
+                "decode_timedelta": False,
+                "strict": True,
+            },
+            {
+                "date": [20240603, 20240604],
+                "time": [0, 1200],
+                "step": [0, 6],
+                "levelist": [500, 700],
+            },
+            {"date": 2, "time": 2, "step": 2, "levelist": 2},
+            {},
+        ),
+        (
+            {
+                "profile": "mars",
+                "dims_as_attrs": "levtype",
+                "time_dim_mode": "raw",
+                "decode_times": False,
+                "decode_timedelta": False,
+                "strict": True,
+            },
+            {
+                "date": [20240603, 20240604],
+                "time": [0, 1200],
+                "step": [0, 6],
+                "levelist": [500, 700],
+            },
+            {"date": 2, "time": 2, "step": 2, "levelist": 2},
+            {"levtype": 2},
+        ),
+        (
+            {
+                "profile": "mars",
                 "dims_as_attrs": None,
                 "time_dim_mode": "raw",
                 "decode_times": False,
                 "decode_timedelta": False,
+                "strict": False,
             },
             {
                 "date": [20240603, 20240604],
@@ -45,10 +95,12 @@ from xr_engine_fixtures import compare_dims  # noqa: E402
         ),
         (
             {
+                "profile": "mars",
                 "dims_as_attrs": "levtype",
                 "time_dim_mode": "raw",
                 "decode_times": False,
                 "decode_timedelta": False,
+                "strict": False,
             },
             {
                 "date": [20240603, 20240604],
@@ -72,3 +124,52 @@ def test_xr_dims_as_attrs(kwargs, coords, dims, attrs):
 
     for k, v in attrs.items():
         ds["t"].attrs[k] == v
+
+
+@pytest.mark.cache
+@pytest.mark.parametrize(
+    "kwargs,coords,dims,attrs",
+    [
+        (
+            {
+                "profile": "mars",
+                "attrs_mode": "fixed",
+                "variable_attrs": [
+                    "shortName",
+                    _get_attrs,
+                    {"edition": _get_attrs_for_key_1, "typeOfLevel": _get_attrs_for_key_2, "param": "test"},
+                ],
+                "time_dim_mode": "raw",
+                "decode_times": False,
+                "decode_timedelta": False,
+                "strict": False,
+            },
+            {
+                "date": [20240603, 20240604],
+                "time": [0, 1200],
+                "step": [0, 6],
+                "levelist": [500, 700],
+            },
+            {"date": 2, "time": 2, "step": 2, "levelist": 2},
+            {
+                "shortName": "t",
+                "gridType": "regular_ll",
+                "units": "K",
+                "edition": "1_test1",
+                "typeOfLevel": "isobaricInhPa_test2",
+                "param": "test",
+            },
+        ),
+    ],
+)
+def test_xr_attrs_types(kwargs, coords, dims, attrs):
+    ds0 = from_source(
+        "url", earthkit_remote_test_data_file("test-data", "xr_engine", "level", "pl_small.grib")
+    )
+
+    ds = ds0.to_xarray(**kwargs)
+    compare_coords(ds, coords)
+    compare_dims(ds, dims, sizes=True)
+
+    for k, v in attrs.items():
+        ds["t"].attrs[k] == v