Repository: activeloopai/deeplake
Pull request: Disallow certain methods for dataset and tensor views
## 🚀 🚀 Pull Request

### Checklist:

- [x]  [My code follows the style guidelines of this project](https://www.notion.so/activeloop/Engineering-Guidelines-d6e502306d0e4133a8ca507516d1baab) and the [Contributing document](https://github.com/activeloopai/Hub/blob/release/2.0/CONTRIBUTING.md)
- [x]  I have commented my code, particularly in hard-to-understand areas
- [x]  I have kept the `coverage-rate` up
- [x]  I have performed a self-review of my own code and resolved any problems
- [x]  I have checked to ensure there aren't any other open [Pull Requests](https://github.com/activeloopai/Hub/pulls) for the same change
- [x]  I have described and made corresponding changes to the relevant documentation
- [x]  New and existing unit tests pass locally with my changes


### Changes
Closes #1163 
<!-- Describe your changes! Describe the scope of this PR's responsibilities. -->

Problem statement:
[FEATURE] Disallow certain methods for dataset and tensor views
## 🚨🚨 Feature Request

- [ ] Related to an existing [Issue](../issues) 
- [x] A new implementation (Improvement, Extension) 

Disallow certain methods for datasets and tensor views. A "view" is a result of slicing a dataset or tensor (ds[10:20], tensor[220:30], etc).

**Dataset.create_tensor()**. This method should only be allowed for the base dataset/group. When called on a view, throw an exception

**Dataset.delete()**. Should not be allowed for dataset views.

**Tensor.append()**, **Tensor.extend()**.  These methods should not be allowed for tensor views

**Dataset.read_only**.  This property should not be modified from a dataset view

Examples:

ds = hub.dataset(....)
ds.create_tensor("x")  -> OK

ds.x.extend(....)  -> OK

ds[10:20].create_tensor("y")  -> NOT OK

ds.x[10:20].extend(...)  -> NOT OK
ds.x[10:20].append(...)  -> NOT OK

ds[10:20].read_only = True  -> NOT OK
ds.read_only = True -> OK

### Description of the possible solution
This can probably be done via a `@no_view`  decorator for these methods.

Difficulty: Medium

Hints from issue discussion:

Commit message: fix

Patch to review:
diff --git a/hub/api/tests/test_api.py b/hub/api/tests/test_api.py
index 4cb4b28a31..a4ea965302 100644
--- a/hub/api/tests/test_api.py
+++ b/hub/api/tests/test_api.py
@@ -6,6 +6,7 @@
 from hub.core.tensor import Tensor
 from hub.tests.common import assert_array_lists_equal
 from hub.util.exceptions import (
+    NoViewError,
     TensorDtypeMismatchError,
     TensorAlreadyExistsError,
     TensorGroupAlreadyExistsError,
@@ -794,3 +795,28 @@ def test_tobytes(memory_ds, compressed_image_paths, audio_paths):
     for i in range(3):
         assert ds.image[i].tobytes() == image_bytes
         assert ds.audio[i].tobytes() == audio_bytes
+
+
+def test_no_view(memory_ds):
+    memory_ds.create_tensor("a")
+    memory_ds.a.extend([0, 1, 2, 3])
+    memory_ds.create_tensor("b")
+    memory_ds.b.extend([4, 5, 6])
+
+    with pytest.raises(NoViewError):
+        memory_ds[:2].create_tensor("c")
+
+    with pytest.raises(NoViewError):
+        memory_ds[:3].create_tensor_like("c", memory_ds.a)
+
+    with pytest.raises(NoViewError):
+        memory_ds[2].delete()
+
+    with pytest.raises(NoViewError):
+        memory_ds[0].read_only = True
+
+    with pytest.raises(NoViewError):
+        memory_ds.a[:2].append(0)
+
+    with pytest.raises(NoViewError):
+        memory_ds.b[:3].extend([3, 4])
diff --git a/hub/core/dataset/dataset.py b/hub/core/dataset/dataset.py
index 81fcb99028..8c3ea4d55d 100644
--- a/hub/core/dataset/dataset.py
+++ b/hub/core/dataset/dataset.py
@@ -4,6 +4,7 @@
 from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union
 
 import hub
+from hub.util.no_view import no_view
 import numpy as np
 from hub.api.info import load_info
 from hub.client.log import logger
@@ -85,6 +86,7 @@ def __init__(
         self.path = path or get_path_from_storage(storage)
         self.storage = storage
         self._read_only = read_only
+        self._read_only_set = False
         base_storage = get_base_storage(storage)
         if (
             not read_only and index is None and isinstance(base_storage, S3Provider)
@@ -92,7 +94,7 @@ def __init__(
             try:
                 lock(base_storage, callback=lambda: self._lock_lost_handler)
             except LockedException:
-                self.read_only = True
+                self.read_only = True  # type: ignore
                 warnings.warn(
                     "Opening dataset in read only mode as another machine has locked it for writing."
                 )
@@ -211,6 +213,7 @@ def __getitem__(
             raise InvalidKeyTypeError(item)
 
     @hub_reporter.record_call
+    @no_view
     def create_tensor(
         self,
         name: str,
@@ -307,6 +310,7 @@ def create_tensor(
         return tensor
 
     @hub_reporter.record_call
+    @no_view
     def create_tensor_like(self, name: str, source: "Tensor") -> "Tensor":
         """Copies the `source` tensor's meta information and creates a new tensor with it. No samples are copied, only the meta/info for the tensor is.
 
@@ -457,13 +461,15 @@ def _register_dataset(self):
     def read_only(self):
         return self._read_only
 
-    @read_only.setter
+    @read_only.setter  # type: ignore
+    @no_view
     def read_only(self, value: bool):
         if value:
             self.storage.enable_readonly()
         else:
             self.storage.disable_readonly()
         self._read_only = value
+        self._read_only_set = True  # To allow _set_derived_attributes to set read_only once after initializing dataset view.
 
     @hub_reporter.record_call
     def pytorch(
@@ -592,6 +598,7 @@ def size_approx(self):
         return size
 
     @hub_reporter.record_call
+    @no_view
     def delete(self, large_ok=False):
         """Deletes the entire dataset from the cache layers (if any) and the underlying storage.
         This is an IRREVERSIBLE operation. Data once deleted can not be recovered.
diff --git a/hub/core/tensor.py b/hub/core/tensor.py
index 004a12c797..f543ee937b 100644
--- a/hub/core/tensor.py
+++ b/hub/core/tensor.py
@@ -1,3 +1,4 @@
+from hub.util.no_view import no_view
 from hub.util.version_control import checkout, generate_hash
 import numpy as np
 from typing import Dict, List, Sequence, Union, Optional, Tuple, Any
@@ -109,6 +110,7 @@ def __init__(
         # An optimization to skip multiple .numpy() calls when performing inplace ops on slices:
         self._skip_next_setitem = False
 
+    @no_view
     def extend(self, samples: Union[np.ndarray, Sequence[SampleValue]]):
         """Extends the end of the tensor by appending multiple elements from a sequence. Accepts a sequence, a single batched numpy array,
         or a sequence of `hub.read` outputs, which can be used to load files. See examples down below.
@@ -158,6 +160,7 @@ def info(self):
             )
         return self._info
 
+    @no_view
     def append(
         self,
         sample: Union[np.ndarray, float, int, Sample],
diff --git a/hub/util/exceptions.py b/hub/util/exceptions.py
index 05a3c885b7..fedab34e8c 100644
--- a/hub/util/exceptions.py
+++ b/hub/util/exceptions.py
@@ -593,6 +593,16 @@ def __init__(self):
         )
 
 
+class NoViewError(Exception):
+    def __init__(self, method, type):
+        if method == "read_only":
+            super().__init__(
+                f"read_only property cannot be toggled for a dataset view."
+            )
+        else:
+            super().__init__(f"{method} method cannot be called on a {type} view.")
+
+
 class AgreementError(Exception):
     pass
 
diff --git a/hub/util/no_view.py b/hub/util/no_view.py
new file mode 100644
index 0000000000..93c46cd55d
--- /dev/null
+++ b/hub/util/no_view.py
@@ -0,0 +1,21 @@
+from hub.util.exceptions import NoViewError
+from typing import Callable
+from functools import wraps
+import hub
+
+
+def no_view(callable: Callable):
+    @wraps(callable)
+    def inner(x, *args, **kwargs):
+        func = callable.__name__
+        if not x.index.is_trivial():
+            if func == "read_only":
+                if not x._read_only_set:
+                    return callable(x, *args, **kwargs)
+            raise NoViewError(
+                func,
+                "Dataset" if isinstance(x, hub.Dataset) else "Tensor",
+            )
+        return callable(x, *args, **kwargs)
+
+    return inner


Code review comments:
diff_hunk: @@ -593,6 +593,16 @@ def __init__(self):
         )
 
 
+class NoViewError(Exception):
path: hub/util/exceptions.py
text: @user1:
could you rename this to InvalidOperationError?