Repository: facebookresearch/habitat-lab

Issue:
Fix contains method in habitat space classes
## 🐛 Bug

The `contains` method for the `EmptySpace`, `ActionSpace`, and `ListSpace` classes in `habitat.core.spaces` all contain errors. Specifically:
1. `EmptySpace.contains(x)` should return `True` if `x is None`.
2. `ActionSpace.contains(x)` improperly handles invalid keys and invalid `x["action"]` values.
3. `ListSpace.contains(x)` improperly checks the length of `x`.

## Command

`EmptySpace.contains()`
`ActionSpace.contains()`
`ListSpace.contains()`

## To Reproduce

Code to reproduce the behavior:

```python
>>> import gym
>>> from habitat.core.spaces import EmptySpace, ActionSpace, ListSpace
>>>
>>> space = EmptySpace()
>>> print(space.contains(None))  # should be True
False
>>>
>>> space = ActionSpace({
...     "move": gym.spaces.Dict({
...         "position": gym.spaces.Discrete(2),
...         "velocity": gym.spaces.Discrete(3)
...     }),
...     "move_forward": EmptySpace(),
... })
>>> space.contains({'action': 'move'}) # should be False
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/mnt/habitat/habitat-api/habitat/core/spaces.py", line 64, in contains
    if not self.spaces[x["action"]].contains(x["action_args"]):
KeyError: 'action_args'
>>>
>>> space.contains({'action': None})  # should be false
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/mnt/habitat/habitat-api/habitat/core/spaces.py", line 64, in contains
    if not self.spaces[x["action"]].contains(x["action_args"]):
KeyError: None
>>>
>>> space = ListSpace(gym.spaces.Discrete(2), 5, 10)
>>> print(space.contains([0, 1, 0, 1]))  # should be True
False
```

## Expected behavior

The `contains` methods should properly return `True`, `False`, `False`, and `True` in the four cases above, respectively.

## Additional context

There is also an error in example code in the docs for the `ActionSpace` class.


Discussion:

Resolving patch:
diff --git a/habitat/core/spaces.py b/habitat/core/spaces.py
--- a/habitat/core/spaces.py
+++ b/habitat/core/spaces.py
@@ -21,8 +21,13 @@ def sample(self):
         return None
 
     def contains(self, x):
+        if x is None:
+            return True
         return False
 
+    def __repr__(self):
+        return "EmptySpace()"
+
 
 class ActionSpace(gym.spaces.Dict):
     """
@@ -30,14 +35,13 @@ class ActionSpace(gym.spaces.Dict):
 
     .. code:: py
 
-        self.observation_space = spaces.ActionSpace(
+        self.observation_space = spaces.ActionSpace({
             "move": spaces.Dict({
                 "position": spaces.Discrete(2),
                 "velocity": spaces.Discrete(3)
-                },
-            "move_forward": EmptySpace,
-            )
-        )
+            }),
+            "move_forward": EmptySpace(),
+        })
     """
 
     def __init__(self, spaces):
@@ -59,9 +63,11 @@ def sample(self):
         }
 
     def contains(self, x):
-        if not isinstance(x, dict) and {"action", "action_args"} not in x:
+        if not isinstance(x, dict) or "action" not in x:
+            return False
+        if x["action"] not in self.spaces:
             return False
-        if not self.spaces[x["action"]].contains(x["action_args"]):
+        if not self.spaces[x["action"]].contains(x.get("action_args", None)):
             return False
         return True
 
@@ -100,7 +106,7 @@ def contains(self, x):
         if not isinstance(x, Sized):
             return False
 
-        if self.min_seq_length <= len(x) <= self.max_seq_length:
+        if not (self.min_seq_length <= len(x) <= self.max_seq_length):
             return False
 
         return all([self.space.contains(el) for el in x])


Test patch:
diff --git a/test/test_habitat_task.py b/test/test_habitat_task.py
--- a/test/test_habitat_task.py
+++ b/test/test_habitat_task.py
@@ -13,8 +13,8 @@
 from habitat.utils.test_utils import sample_non_stop_action
 
 CFG_TEST = "configs/test/habitat_all_sensors_test.yaml"
-TELEPORT_POSITION = [-3.2890449, 0.15067159, 11.124366]
-TELEPORT_ROTATION = [0.92035, 0, -0.39109465, 0]
+TELEPORT_POSITION = np.array([-3.2890449, 0.15067159, 11.124366])
+TELEPORT_ROTATION = np.array([0.92035, 0, -0.39109465, 0])
 
 
 def test_task_actions():
@@ -25,15 +25,15 @@ def test_task_actions():
 
     env = habitat.Env(config=config)
     env.reset()
-    env.step(
-        action={
-            "action": "TELEPORT",
-            "action_args": {
-                "position": TELEPORT_POSITION,
-                "rotation": TELEPORT_ROTATION,
-            },
-        }
-    )
+    action = {
+        "action": "TELEPORT",
+        "action_args": {
+            "position": TELEPORT_POSITION,
+            "rotation": TELEPORT_ROTATION,
+        },
+    }
+    assert env.action_space.contains(action)
+    env.step(action)
     agent_state = env.sim.get_agent_state()
     assert np.allclose(
         np.array(TELEPORT_POSITION, dtype=np.float32), agent_state.position
@@ -56,6 +56,7 @@ def test_task_actions_sampling_for_teleport():
     env.reset()
     while not env.episode_over:
         action = sample_non_stop_action(env.action_space)
+        assert env.action_space.contains(action)
         habitat.logger.info(
             f"Action : "
             f"{action['action']}, "
@@ -89,6 +90,7 @@ def test_task_actions_sampling(config_file):
     env.reset()
     while not env.episode_over:
         action = sample_non_stop_action(env.action_space)
+        assert env.action_space.contains(action)
         habitat.logger.info(
             f"Action : "
             f"{action['action']}, "
diff --git a/test/test_spaces.py b/test/test_spaces.py
new file mode 100644
--- /dev/null
+++ b/test/test_spaces.py
@@ -0,0 +1,55 @@
+#!/usr/bin/env python3
+
+# Copyright (c) Facebook, Inc. and its affiliates.
+# This source code is licensed under the MIT license found in the
+# LICENSE file in the root directory of this source tree.
+
+import gym
+import pytest
+
+from habitat.core.spaces import ActionSpace, EmptySpace, ListSpace
+
+
+def test_empty_space():
+    space = EmptySpace()
+    assert space.contains(space.sample())
+    assert space.contains(None)
+    assert not space.contains(0)
+
+
+def test_action_space():
+    space = ActionSpace(
+        {
+            "move": gym.spaces.Dict(
+                {
+                    "position": gym.spaces.Discrete(2),
+                    "velocity": gym.spaces.Discrete(3),
+                }
+            ),
+            "move_forward": EmptySpace(),
+        }
+    )
+    assert space.contains(space.sample())
+    assert space.contains(
+        {"action": "move", "action_args": {"position": 0, "velocity": 1}}
+    )
+    assert space.contains({"action": "move_forward"})
+    assert not space.contains([0, 1, 2])
+    assert not space.contains({"zero": None})
+    assert not space.contains({"action": "bad"})
+    assert not space.contains({"action": "move"})
+    assert not space.contains(
+        {"action": "move", "action_args": {"position": 0}}
+    )
+    assert not space.contains(
+        {"action": "move_forward", "action_args": {"position": 0}}
+    )
+
+
+def test_list_space():
+    space = ListSpace(gym.spaces.Discrete(2), 5, 10)
+    assert space.contains(space.sample())
+    assert not space.contains(0)
+    assert not space.contains([0] * 4)
+    assert not space.contains([2] * 5)
+    assert not space.contains([1] * 11)