Repository: pytorch/rl

Issue:
[BUG] Prevent the same transform to be placed twice in a transform list
## Describe the bug

Placing the same transform object twice in a TransformedEnv leads to poorly defined behaviour when calling `transform.parent`.

## To Reproduce

```python
from torchrl.envs import TransformedEnv, RewardScaling, RewardClipping, Compose
from torchrl.envs.libs.gym import GymEnv

env =  TransformedEnv(GymEnv("Pendulum-v1"))
t1 = RewardScaling(0, 1)
t2 = RewardClipping(0.1, 0.5)
env.append_transform(t1)
env.append_transform(t2)
env.append_transform(t1)
print(env.transform[-1])
```


## Expected behavior

This prints an env without transform, but if anything it should show an env with transforms `t1` and `t2`.

## Reason and Possible fixes

Once a transform has a non-null `_parent`, it should not be possible to put it in a transformed env anymore (or there should be a strong reset method that should allow to do that, but it's unclear how it should signal to the parent TransformedEnv that this transform has changed parent).



Discussion:

Resolving patch:
diff --git a/torchrl/envs/transforms/transforms.py b/torchrl/envs/transforms/transforms.py
--- a/torchrl/envs/transforms/transforms.py
+++ b/torchrl/envs/transforms/transforms.py
@@ -229,8 +229,13 @@ def __repr__(self) -> str:
         return f"{self.__class__.__name__}(keys={self.keys_in})"
 
     def set_parent(self, parent: Union[Transform, EnvBase]) -> None:
+        if self.__dict__["_parent"] is not None:
+            raise AttributeError("parent of transform already set")
         self.__dict__["_parent"] = parent
 
+    def reset_parent(self) -> None:
+        self.__dict__["_parent"] = None
+
     @property
     def parent(self) -> EnvBase:
         if not hasattr(self, "_parent"):
@@ -251,15 +256,20 @@ def parent(self) -> EnvBase:
                 raise ValueError(
                     f"Compose parent was of type {type(compose_parent)} but expected TransformedEnv."
                 )
+            if compose_parent.transform is not compose:
+                comp_parent_trans = copy(compose_parent.transform)
+                comp_parent_trans.reset_parent()
+            else:
+                comp_parent_trans = None
             out = TransformedEnv(
                 compose_parent.base_env,
-                transform=compose_parent.transform
-                if compose_parent.transform is not compose
-                else None,
+                transform=comp_parent_trans,
             )
-            for transform in compose.transforms:
-                if transform is self:
+            for orig_trans in compose.transforms:
+                if orig_trans is self:
                     break
+                transform = copy(orig_trans)
+                transform.reset_parent()
                 out.append_transform(transform)
         elif isinstance(parent, TransformedEnv):
             out = TransformedEnv(parent.base_env)
@@ -312,9 +322,16 @@ def __init__(
                 # we don't use isinstance as some transforms may be subclassed from
                 # Compose but with other features that we don't want to loose.
                 transform = [transform]
+            else:
+                for t in transform:
+                    t.reset_parent()
             env_transform = env.transform
             if type(env_transform) is not Compose:
+                env_transform.reset_parent()
                 env_transform = [env_transform]
+            else:
+                for t in env_transform:
+                    t.reset_parent()
             transform = Compose(*env_transform, *transform).to(device)
         else:
             self._set_env(env, device)
@@ -499,9 +516,10 @@ def append_transform(self, transform: Transform) -> None:
         transform = transform.to(self.device)
         if not isinstance(self.transform, Compose):
             prev_transform = self.transform
+            prev_transform.reset_parent()
             self.transform = Compose()
             self.transform.append(prev_transform)
-            self.transform.set_parent(self)
+
         self.transform.append(transform)
 
     def insert_transform(self, index: int, transform: Transform) -> None:
@@ -563,8 +581,6 @@ def to(self, device: DEVICE_TYPING) -> TransformedEnv:
     def __setattr__(self, key, value):
         propobj = getattr(self.__class__, key, None)
 
-        if isinstance(value, Transform):
-            value.set_parent(self)
         if isinstance(propobj, property):
             ancestors = list(__class__.__mro__)[::-1]
             while isinstance(propobj, property):


Test patch:
diff --git a/test/test_transforms.py b/test/test_transforms.py
--- a/test/test_transforms.py
+++ b/test/test_transforms.py
@@ -327,6 +327,27 @@ def test_nested_transformed_env():
     assert children[1] == t2
 
 
+def test_transform_parent():
+    base_env = ContinuousActionVecMockEnv()
+    t1 = RewardScaling(0, 1)
+    t2 = RewardScaling(0, 2)
+    env = TransformedEnv(TransformedEnv(base_env, t1), t2)
+    t3 = RewardClipping(0.1, 0.5)
+    env.append_transform(t3)
+
+    t1_parent_gt = t1._parent
+    t2_parent_gt = t2._parent
+    t3_parent_gt = t3._parent
+
+    _ = t1.parent
+    _ = t2.parent
+    _ = t3.parent
+
+    assert t1_parent_gt == t1._parent
+    assert t2_parent_gt == t2._parent
+    assert t3_parent_gt == t3._parent
+
+
 class TestTransforms:
     @pytest.mark.skipif(not _has_tv, reason="no torchvision")
     @pytest.mark.parametrize("interpolation", ["bilinear", "bicubic"])