Repository: vyperlang/vyper

Issue:
no support for `not in` comparator
### Version Information

* vyper Version (output of `vyper --version`): `0.2.7`
* Python Version (output of `python --version`): `3.8.5`

### What's your issue about?
The `not in` comparator is not supported. The following fails to compile with a `SyntaxException`:

```python
@external
def foo(a: address[3]):
    if self not in a:
        pass
```

However, the desired behavior is possible by instead using the `not` unary operator with the `in` comparator. The following example compiles:

```python
@external
def foo(a: address[3]):
    if not self in a:
        pass
```


### How can it be fixed?
Add support for `not in`.



Discussion:

Resolving patch:
diff --git a/vyper/ast/nodes.py b/vyper/ast/nodes.py
--- a/vyper/ast/nodes.py
+++ b/vyper/ast/nodes.py
@@ -1090,6 +1090,14 @@ def _op(self, left, right):
         return left in right
 
 
+class NotIn(VyperNode):
+    __slots__ = ()
+    _description = "exclusion"
+
+    def _op(self, left, right):
+        return left not in right
+
+
 class Call(VyperNode):
     __slots__ = ("func", "args", "keywords", "keyword")
 
diff --git a/vyper/context/validation/utils.py b/vyper/context/validation/utils.py
--- a/vyper/context/validation/utils.py
+++ b/vyper/context/validation/utils.py
@@ -162,7 +162,7 @@ def types_from_BoolOp(self, node):
 
     def types_from_Compare(self, node):
         # comparison: `x < y`
-        if isinstance(node.op, vy_ast.In):
+        if isinstance(node.op, (vy_ast.In, vy_ast.NotIn)):
             # x in y
             left = self.get_possible_types_from_node(node.left)
             right = self.get_possible_types_from_node(node.right)
diff --git a/vyper/parser/expr.py b/vyper/parser/expr.py
--- a/vyper/parser/expr.py
+++ b/vyper/parser/expr.py
@@ -675,12 +675,11 @@ def build_in_comparator(self):
         else:
             compare_sequence = ["seq"] + for_loop_sequence
 
-        # Compare the result of the repeat loop to 1, to know if a match was found.
-        lll_node = LLLnode.from_list(
-            ["eq", 1, compare_sequence], typ="bool", annotation="in comporator"
-        )
+        if isinstance(self.expr.op, vy_ast.NotIn):
+            # for `not in`, invert the result
+            compare_sequence = ["iszero", compare_sequence]
 
-        return lll_node
+        return LLLnode.from_list(compare_sequence, typ="bool", annotation="in comparator")
 
     @staticmethod
     def _signed_to_unsigned_comparision_op(op):
@@ -706,7 +705,9 @@ def parse_Compare(self):
             # TODO: Can this if branch be removed ^
             pass
 
-        elif isinstance(self.expr.op, vy_ast.In) and isinstance(right.typ, ListType):
+        elif isinstance(self.expr.op, (vy_ast.In, vy_ast.NotIn)) and isinstance(
+            right.typ, ListType
+        ):
             if left.typ != right.typ.subtype:
                 return
             return self.build_in_comparator()


Test patch:
diff --git a/tests/grammar/vyper.lark b/tests/grammar/vyper.lark
--- a/tests/grammar/vyper.lark
+++ b/tests/grammar/vyper.lark
@@ -230,6 +230,7 @@ _IN: "in"
            | comparator _LE atom ->  le
            | comparator _GE atom ->  ge
            | comparator _IN atom ->  in
+           | comparator _NOT _IN atom ->  in
 
 // NOTE: Must end recursive cycle like this (with `atom` calling `operation`)
 ?atom: variable_access
diff --git a/tests/parser/features/iteration/test_range_in.py b/tests/parser/features/iteration/test_range_in.py
--- a/tests/parser/features/iteration/test_range_in.py
+++ b/tests/parser/features/iteration/test_range_in.py
@@ -80,6 +80,24 @@ def in_test(x: int128) -> bool:
     assert c.in_test(7) is True
 
 
+def test_cmp_not_in_list(get_contract_with_gas_estimation):
+    code = """
+@external
+def in_test(x: int128) -> bool:
+    if x not in [9, 7, 6, 5]:
+        return True
+    return False
+    """
+
+    c = get_contract_with_gas_estimation(code)
+
+    assert c.in_test(1) is True
+    assert c.in_test(-7) is True
+    assert c.in_test(-9) is True
+    assert c.in_test(5) is False
+    assert c.in_test(7) is False
+
+
 def test_mixed_in_list(assert_compile_failed, get_contract_with_gas_estimation):
     code = """
 @external