Repository: ecmwf/earthkit-data
Pull request #683 (2025-04-24 16:21:25)

feat: Add override to UserMetadata
# Update UserMetadata

- Add override method
   - Complete deep copy
- Add dump

## Allows
```python
from earthkit.data.utils.metadata.dict import UserMetadata

metadata = UserMetadata({'shortName':'2t'})
metadata.override(shortName = 'msl')
```

codecov-commenter: ## [Codecov](https://app.codecov.io/gh/ecmwf/earthkit-data/pull/683?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 90.86%. Comparing base [(`006632f`)](https://app.codecov.io/gh/ecmwf/earthkit-data/commit/006632fd851778ce5d9bc7410716be63c9d82c02?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ecmwf) to head [(`5ae864b`)](https://app.codecov.io/gh/ecmwf/earthkit-data/commit/5ae864b4909525083b9dd2b406e192390d17bfbf?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     #683      +/-   ##
===========================================
+ Coverage    90.85%   90.86%   +0.01%     
===========================================
  Files          160      160              
  Lines        12136    12155      +19     
  Branches       593      597       +4     
===========================================
+ Hits         11026    11045      +19     
  Misses         930      930              
  Partials       180      180              
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/ecmwf/earthkit-data/pull/683?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).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
sandorkertesz: @HCookie, many thanks for adding these features backed with proper testing! Once the code is updated with the latest develop it can be merged.

Patch:
diff --git a/src/earthkit/data/utils/metadata/dict.py b/src/earthkit/data/utils/metadata/dict.py
index fa22d02..7623750 100644
--- a/src/earthkit/data/utils/metadata/dict.py
+++ b/src/earthkit/data/utils/metadata/dict.py
@@ -7,6 +7,7 @@
 # nor does it submit to any jurisdiction.
 #
 
+import copy
 import logging
 from functools import cached_property
 from math import prod
@@ -452,7 +453,26 @@ class UserMetadata(Metadata):
         return make_geography(self, values_shape=self._shape)
 
     def override(self, *args, **kwargs):
-        raise NotImplementedError("override is not implemented for UserMetadata")
+        r"""Create a new metadata object by cloning the underlying metadata and setting the keys in it.
+
+        Parameters
+        ----------
+        *args: tuple
+            Positional arguments. When present must be a dict with the keys to set in
+            the new metadata.
+        **kwargs: dict, optional
+            Other keyword arguments specifying the metadata keys to set.
+
+        Returns
+        -------
+        :class:`UserMetadata`
+            The new metadata object. A copy of the original metadata with the keys set in it.
+        """
+        d = dict(*args, **kwargs)
+        existing = copy.deepcopy(self._data)
+        existing.update(d)
+
+        return UserMetadata(existing, shape=copy.deepcopy(self._shape))
 
     def namespaces(self):
         return []
@@ -461,7 +481,35 @@ class UserMetadata(Metadata):
         return {}
 
     def dump(self, **kwargs):
-        return None
+        r"""Generate dump with all the metadata keys.
+
+        In a Jupyter notebook it is represented as a tabbed interface.
+
+        Parameters
+        ----------
+        **kwargs: dict, optional
+            Other keyword arguments used for testing only
+
+        Returns
+        -------
+        NamespaceDump
+            Dict-like object with one item per namespace. In a Jupyter notebook represented
+            as a tabbed interface to browse the dump contents.
+
+        Examples
+        --------
+        :ref:`/examples/grib_metadata.ipynb`
+
+        """
+        from earthkit.data.utils.summary import format_namespace_dump
+
+        r = [
+            {
+                "title": "metadata",
+                "data": self._data,
+            }
+        ]
+        return format_namespace_dump(r, selected="parameter", details=self.__class__.__name__, **kwargs)
 
     def ls_keys(self):
         return self.LS_KEYS


Test patch:
diff --git a/tests/array_fieldlist/test_array_field_usermetadata.py b/tests/array_fieldlist/test_array_field_usermetadata.py
index 2ac6146..ff1774e 100644
--- a/tests/array_fieldlist/test_array_field_usermetadata.py
+++ b/tests/array_fieldlist/test_array_field_usermetadata.py
@@ -9,6 +9,7 @@
 # nor does it submit to any jurisdiction.
 #
 
+import copy
 import datetime
 
 import numpy as np
@@ -73,3 +74,59 @@ def test_array_field_usermetadata_geom(_kwargs):
     assert np.allclose(lat, meta["latitudes"])
     assert np.allclose(lon, meta["longitudes"])
     assert np.allclose(f.values, vals)
+
+
+@pytest.mark.parametrize(
+    "initial, update, expected",
+    [
+        ({"shortName": "2t"}, {}, {"shortName": "2t"}),  # No update
+        ({"shortName": "2t"}, {"shortName": "msl"}, {"shortName": "msl"}),  # Update existing key
+        (
+            {"shortName": "2t"},
+            {"longName": "Temperature"},
+            {"shortName": "2t", "longName": "Temperature"},
+        ),  # Add new key
+        ({}, {"shortName": "2t"}, {"shortName": "2t"}),  # Add key to empty metadata
+        (
+            {"shortName": "2t", "longName": "Temperature"},
+            {"shortName": "temperature"},
+            {"shortName": "temperature", "longName": "Temperature"},
+        ),  # Update one key, keep others
+    ],
+)
+def test_array_field_usermetadata_override(initial, update, expected):
+
+    initial_copied = copy.deepcopy(initial)
+    update_copied = copy.deepcopy(update)
+
+    meta = UserMetadata(initial)
+    new_meta = meta.override(update)
+    _ = meta.override(**update)  # Check that the override method works with keyword arguments
+
+    # Check that the updated metadata matches the expected result
+    for k, v in expected.items():
+        assert new_meta[k] == v
+
+    # Ensure the original metadata remains unchanged
+    for k, v in initial_copied.items():
+        assert meta[k] == v
+
+    # Check that the updated metadata contains all expected keys
+    for k in update_copied:
+        if k in initial:
+            assert meta[k] == initial[k]
+        else:
+            assert k not in meta
+
+
+def test_array_field_usermetadata_override_shape():
+    meta = UserMetadata({}, shape=(10, 1))
+    new_meta = meta.override(
+        {
+            "shortName": "2t",
+            "longName": "Temperature",
+        }
+    )
+    new_meta._shape = None
+    assert new_meta._shape is None
+    assert meta._shape is not None