Repository: lhotse-speech/lhotse

Issue:
Why can't the custom attributes in custom field of SupervisionSegment be removed like that in Cut?
SupervisionSegment: https://github.com/lhotse-speech/lhotse/blob/eb212985fee8fa5165e24a2b45fdac9a43f6e54d/lhotse/supervision.py#L459-L470
Cut: https://github.com/lhotse-speech/lhotse/blob/eb212985fee8fa5165e24a2b45fdac9a43f6e54d/lhotse/cut/data.py#L73-L89


Discussion:
I didn't understand your question; can you elaborate?
I think he is pointing out that one of them has a mechanism where you can
assign a None to remove the entry.
(Although I suppose the usual python mechanism for that is __del__ ?)

On Tue, Aug 1, 2023 at 1:28 AM Piotr Żelasko ***@***.***>
wrote:

> I didn't understand your question; can you elaborate?
>
> —
> Reply to this email directly, view it on GitHub
> <https://github.com/lhotse-speech/lhotse/issues/1111#issuecomment-1658841381>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/AAZFLO5X3EO3D4TS246GSJTXS7TKPANCNFSM6AAAAAA26OICWQ>
> .
> You are receiving this because you are subscribed to this thread.Message
> ID: ***@***.***>
>

> I think he is pointing out that one of them has a mechanism where you can assign a None to remove the entry. (Although I suppose the usual python mechanism for that is __del__ ?)
> […](#)
> On Tue, Aug 1, 2023 at 1:28 AM Piotr Żelasko ***@***.***> wrote: I didn't understand your question; can you elaborate? — Reply to this email directly, view it on GitHub <[#1111 (comment)](https://github.com/lhotse-speech/lhotse/issues/1111#issuecomment-1658841381)>, or unsubscribe <https://github.com/notifications/unsubscribe-auth/AAZFLO5X3EO3D4TS246GSJTXS7TKPANCNFSM6AAAAAA26OICWQ> . You are receiving this because you are subscribed to this thread.Message ID: ***@***.***>

Yes.
I can use `del SupervisionSegment.custom` to remove the whole custom field and `del SupervisionSegment.custom['name']` to remove the attributes in the custom field.
I note that I can assign a None to remove the entry in Cut, but SupervisionSegment doesn't support this mechanism.

Resolving patch:
diff --git a/lhotse/cut/data.py b/lhotse/cut/data.py
--- a/lhotse/cut/data.py
+++ b/lhotse/cut/data.py
@@ -70,7 +70,7 @@ class DataCut(Cut, metaclass=ABCMeta):
     # Store anything else the user might want.
     custom: Optional[Dict[str, Any]] = None
 
-    def __setattr__(self, key: str, value: Any):
+    def __setattr__(self, key: str, value: Any) -> None:
         """
         This magic function is called when the user tries to set an attribute.
         We use it as syntactic sugar to store custom attributes in ``self.custom``
@@ -123,6 +123,14 @@ def __getattr__(self, name: str) -> Any:
             return partial(self.load_custom, attr_name)
         raise AttributeError(f"No such attribute: {name}")
 
+    def __delattr__(self, key: str) -> None:
+        """Used to support ``del cut.custom_attr`` syntax."""
+        if key in self.__dataclass_fields__:
+            super().__delattr__(key)
+        if self.custom is None or key not in self.custom:
+            raise AttributeError(f"No such member: '{key}'")
+        del self.custom[key]
+
     def load_custom(self, name: str) -> np.ndarray:
         """
         Load custom data as numpy array. The custom data is expected to have
diff --git a/lhotse/supervision.py b/lhotse/supervision.py
--- a/lhotse/supervision.py
+++ b/lhotse/supervision.py
@@ -456,7 +456,7 @@ def from_dict(data: dict) -> "SupervisionSegment":
 
         return SupervisionSegment(**data)
 
-    def __setattr__(self, key: str, value: Any):
+    def __setattr__(self, key: str, value: Any) -> None:
         """
         This magic function is called when the user tries to set an attribute.
         We use it as syntactic sugar to store custom attributes in ``self.custom``
@@ -466,8 +466,12 @@ def __setattr__(self, key: str, value: Any):
             super().__setattr__(key, value)
         else:
             custom = ifnone(self.custom, {})
-            custom[key] = value
-            self.custom = custom
+            if value is None:
+                custom.pop(key, None)
+            else:
+                custom[key] = value
+            if custom:
+                self.custom = custom
 
     def __getattr__(self, name: str) -> Any:
         """
@@ -492,6 +496,14 @@ def __getattr__(self, name: str) -> Any:
         except:
             raise AttributeError(f"No such attribute: {name}")
 
+    def __delattr__(self, key: str) -> None:
+        """Used to support ``del supervision.custom_attr`` syntax."""
+        if key in self.__dataclass_fields__:
+            super().__delattr__(key)
+        if self.custom is None or key not in self.custom:
+            raise AttributeError(f"No such member: '{key}'")
+        del self.custom[key]
+
 
 class SupervisionSet(Serializable, AlgorithmMixin):
     """


Test patch:
diff --git a/test/cut/test_custom_attrs.py b/test/cut/test_custom_attrs.py
--- a/test/cut/test_custom_attrs.py
+++ b/test/cut/test_custom_attrs.py
@@ -16,7 +16,12 @@
     validate,
 )
 from lhotse.serialization import deserialize_item
-from lhotse.testing.dummies import dummy_cut, dummy_recording
+from lhotse.testing.dummies import (
+    dummy_cut,
+    dummy_multi_cut,
+    dummy_recording,
+    dummy_supervision,
+)
 
 
 @pytest.mark.parametrize("cut", [dummy_cut(1), dummy_cut(2).pad(300)])
@@ -366,3 +371,32 @@ def test_cut_attach_tensor_temporal():
     cut = cut.attach_tensor("ivector", ivector)
     restored_ivector = cut.load_ivector()
     np.testing.assert_equal(ivector, restored_ivector)
+
+
+def test_del_attr_supervision():
+    sup = dummy_supervision(0)
+
+    with pytest.raises(AttributeError):
+        del sup.nonexistent_attribute
+
+    sup.extra_metadata = {"version": "0.1.1"}
+    assert "extra_metadata" in sup.custom
+    sup.extra_metadata  # does not raise
+    del sup.extra_metadata
+    with pytest.raises(AttributeError):
+        del sup.extra_metadata
+    assert "extra_metadata" not in sup.custom
+
+
+@pytest.mark.parametrize("cut", [dummy_cut(0), dummy_multi_cut(0)])
+def test_del_attr_mono_cut(cut):
+    with pytest.raises(AttributeError):
+        del cut.nonexistent_attribute
+
+    cut.extra_metadata = {"version": "0.1.1"}
+    assert "extra_metadata" in cut.custom
+    cut.extra_metadata  # does not raise
+    del cut.extra_metadata
+    with pytest.raises(AttributeError):
+        del cut.extra_metadata
+    assert "extra_metadata" not in cut.custom