Repository: Parquery/icontract

Issue:
Violating contract with assignment expression produces NotImplementedError
An assignment expression in a lambda expression in a contract produces a  `NotImplementedError` from `incontract._recompute` when a call violates the contract.  A call that conforms to the contract does not produce the error.  Using Python 3.8.5 and icontract 2.3.7.

Example file `test_foo.py`:
``` python
from icontract import ViolationError, require
from pytest import raises

@require(lambda x: (t := x + 1) and t > 1)
def include(x: int) -> int:
    return x

def test_include_a():
    with raises(ViolationError):
        include(0)

def test_include_b():
    assert include(1) == 1
```

``` bash
$ pytest test_foo.py 
============================= test session starts ==============================
platform linux -- Python 3.8.5, pytest-6.1.2, py-1.9.0, pluggy-0.13.1
rootdir: /tmp/foo
collected 2 items                                                              

test_foo.py F.                                                           [100%]

=================================== FAILURES ===================================
________________________________ test_include_a ________________________________

    def test_include_a():
        with raises(ViolationError):
>           include(0)

test_foo.py:10: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
test-env/lib/python3.8/site-packages/icontract/_checkers.py:373: in wrapper
    _assert_precondition(contract=contract, resolved_kwargs=resolved_kwargs)
test-env/lib/python3.8/site-packages/icontract/_checkers.py:152: in _assert_precondition
    msg = icontract._represent.generate_message(contract=contract, condition_kwargs=condition_kwargs)
test-env/lib/python3.8/site-packages/icontract/_represent.py:387: in generate_message
    repr_vals = repr_values(
test-env/lib/python3.8/site-packages/icontract/_represent.py:334: in repr_values
    recompute_visitor.visit(node=lambda_inspection.node.body)
/nix/store/z65l1jqvxa58zzwwa3bvglb6asj4y8cv-python3-3.8.5/lib/python3.8/ast.py:363: in visit
    return visitor(node)
test-env/lib/python3.8/site-packages/icontract/_recompute.py:213: in visit_BoolOp
    values = [self.visit(value_node) for value_node in node.values]
test-env/lib/python3.8/site-packages/icontract/_recompute.py:213: in <listcomp>
    values = [self.visit(value_node) for value_node in node.values]
/nix/store/z65l1jqvxa58zzwwa3bvglb6asj4y8cv-python3-3.8.5/lib/python3.8/ast.py:363: in visit
    return visitor(node)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <icontract._recompute.Visitor object at 0x7fb534f9ea00>
node = <_ast.NamedExpr object at 0x7fb534f9e250>

    def generic_visit(self, node: ast.AST) -> None:
        """Raise an exception that this node has not been handled."""
>       raise NotImplementedError("Unhandled recomputation of the node: {} {}".format(type(node), node))
E       NotImplementedError: Unhandled recomputation of the node: <class '_ast.NamedExpr'> <_ast.NamedExpr object at 0x7fb534f9e250>

test-env/lib/python3.8/site-packages/icontract/_recompute.py:449: NotImplementedError
=========================== short test summary info ============================
FAILED test_foo.py::test_include_a - NotImplementedError: Unhandled recomputa...
========================= 1 failed, 1 passed in 0.31s ==========================
```
Using a named function instead of a lambda in the contract avoids the error.

Example file `test_bar.py`:
``` python
from icontract import ViolationError, require
from pytest import raises

def want(x: int):
    t = x + 1
    return t > 1

@require(want)
def include2(x: int) -> int:
    return x

def test_include2_a():
    with raises(ViolationError):
        include2(0)

def test_include2_b():
    assert include2(1) == 1
```

``` bash
$ pytest test_bar.py
============================= test session starts ==============================
platform linux -- Python 3.8.5, pytest-6.1.2, py-1.9.0, pluggy-0.13.1
rootdir: /tmp/foo
collected 2 items                                                              

test_bar.py ..                                                           [100%]

============================== 2 passed in 0.04s ===============================
```

Comments:

Patch:
diff --git a/.travis.yml b/.travis.yml
index 32624d8..7155fe1 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -3,7 +3,7 @@ python:
   - "3.5"
   - "3.6"
   - "3.7"
-  - "3.8"
+  - "3.8.5"
 install:
   - pip3 install -e .[dev]
   - pip3 install coveralls
diff --git a/icontract/_recompute.py b/icontract/_recompute.py
index cbe7709..00faac1 100644
--- a/icontract/_recompute.py
+++ b/icontract/_recompute.py
@@ -5,7 +5,8 @@ import builtins
 import functools
 import platform
 import sys
-from typing import Any, Mapping, Dict, List, Optional, Union, Tuple, Set, Callable  # pylint: disable=unused-import
+from typing import Any, Mapping, Dict, List, Optional, Union, Tuple, Set, Callable, \
+    cast  # pylint: disable=unused-import
 
 
 class Placeholder:
@@ -37,8 +38,11 @@ class Visitor(ast.NodeVisitor):
 
         :param variable_lookup: list of lookup tables to look-up the values of the variables, sorted by precedence
         """
-        # Resolve precedence of variable lookup
+        # _name_to_value maps the variable names to variable values.
+        # This is important for Load contexts as well as Store contexts in, e.g., named expressions.
         self._name_to_value = dict()  # type: Dict[str, Any]
+
+        # Resolve precedence of variable lookups
         for lookup in variable_lookup:
             for name, value in lookup.items():
                 if name not in self._name_to_value:
@@ -314,6 +318,24 @@ class Visitor(ast.NodeVisitor):
         self.recomputed_values[node] = result
         return result
 
+    if sys.version_info >= (3, 8):
+        # pylint: disable=no-member
+        def visit_NamedExpr(self, node: ast.NamedExpr) -> Any:
+            """Visit the node's ``value`` and assign it to both this node and the target."""
+            value = self.visit(node=node.value)
+            self.recomputed_values[node] = value
+
+            # This assignment is needed to make mypy happy.
+            target = cast(ast.Name, node.target)
+
+            if not isinstance(target.ctx, ast.Store):
+                raise NotImplementedError(
+                    "Expected Store context in the target of a named expression, but got: {}".format(target.ctx))
+
+            self._name_to_value[target.id] = value
+
+            return value
+
     def visit_Index(self, node: ast.Index) -> Any:
         """Visit the node's ``value``."""
         result = self.visit(node=node.value)
diff --git a/icontract/_represent.py b/icontract/_represent.py
index 81a2082..934b052 100644
--- a/icontract/_represent.py
+++ b/icontract/_represent.py
@@ -3,9 +3,10 @@ import ast
 import inspect
 import re
 import reprlib
+import sys
 import textwrap
 import uuid
-from typing import Any, Mapping, MutableMapping, Callable, List, Dict, Iterable  # pylint: disable=unused-import
+from typing import Any, Mapping, MutableMapping, Callable, List, Dict, Iterable, cast  # pylint: disable=unused-import
 from typing import Optional, Tuple  # pylint: disable=unused-import
 
 import asttokens
@@ -88,6 +89,21 @@ class Visitor(ast.NodeVisitor):
 
         self.generic_visit(node=node)
 
+    if sys.version_info >= (3, 8):
+        # pylint: disable=no-member
+        def visit_NamedExpr(self, node: ast.NamedExpr) -> Any:
+            """Represent the target with the value of the node."""
+            if node in self._recomputed_values:
+                value = self._recomputed_values[node]
+
+                # This is necessary in order to make mypy happy.
+                target = cast(ast.Name, node.target)
+
+                if _representable(value=value):
+                    self.reprs[target.id] = value
+
+            self.generic_visit(node=node)
+
     def visit_Call(self, node: ast.Call) -> None:
         """Represent the call by dumping its source code."""
         if node in self._recomputed_values:
diff --git a/precommit.py b/precommit.py
index 1592666..926809e 100755
--- a/precommit.py
+++ b/precommit.py
@@ -24,6 +24,10 @@ def main() -> int:
 
     print("YAPF'ing...")
     yapf_targets = ["tests", "icontract", "setup.py", "precommit.py", "benchmark.py", "benchmarks", "tests_with_others"]
+
+    if sys.version_info >= (3, 8, 5):
+        yapf_targets.append('tests_3_8')
+
     if overwrite:
         subprocess.check_call(
             ["yapf", "--in-place", "--style=style.yapf", "--recursive"] + yapf_targets, cwd=str(repo_root))
@@ -32,10 +36,18 @@ def main() -> int:
             ["yapf", "--diff", "--style=style.yapf", "--recursive"] + yapf_targets, cwd=str(repo_root))
 
     print("Mypy'ing...")
-    subprocess.check_call(["mypy", "--strict", "icontract", "tests"], cwd=str(repo_root))
+    mypy_targets = ["icontract", "tests"]
+    if sys.version_info >= (3, 8):
+        mypy_targets.append('tests_3_8')
+
+    subprocess.check_call(["mypy", "--strict"] + mypy_targets, cwd=str(repo_root))
 
     print("Pylint'ing...")
-    subprocess.check_call(["pylint", "--rcfile=pylint.rc", "tests", "icontract"], cwd=str(repo_root))
+    pylint_targets = ['icontract', 'tests']
+
+    if sys.version_info >= (3, 8):
+        pylint_targets.append('tests_3_8')
+    subprocess.check_call(["pylint", "--rcfile=pylint.rc"] + pylint_targets, cwd=str(repo_root))
 
     print("Pydocstyle'ing...")
     subprocess.check_call(["pydocstyle", "icontract"], cwd=str(repo_root))
@@ -45,10 +57,14 @@ def main() -> int:
     env['ICONTRACT_SLOW'] = 'true'
 
     # yapf: disable
+    unittest_targets = ['tests']
+    if sys.version_info > (3, 8):
+        unittest_targets.append('tests_3_8')
+
     subprocess.check_call(
         ["coverage", "run",
          "--source", "icontract",
-         "-m", "unittest", "discover", "tests"],
+         "-m", "unittest", "discover"] + unittest_targets,
         cwd=str(repo_root),
         env=env)
     # yapf: enable


Test patch:
diff --git a/tests_3_8/__init__.py b/tests_3_8/__init__.py
new file mode 100644
index 0000000..774c18e
--- /dev/null
+++ b/tests_3_8/__init__.py
@@ -0,0 +1,7 @@
+"""
+Test Python 3.8-specific features.
+
+For example, one such feature is walrus operator used in named expressions.
+We have to exclude these tests running on prior versions of Python since the syntax would be considered
+invalid.
+"""
diff --git a/tests_3_8/test_represent.py b/tests_3_8/test_represent.py
new file mode 100644
index 0000000..2aa9d99
--- /dev/null
+++ b/tests_3_8/test_represent.py
@@ -0,0 +1,35 @@
+#!/usr/bin/env python3
+# pylint: disable=missing-docstring,invalid-name,too-many-public-methods,no-self-use
+# pylint: disable=unused-argument
+
+import textwrap
+import unittest
+from typing import Optional  # pylint: disable=unused-import
+
+import icontract._represent
+import tests.error
+import tests.mock
+
+
+class TestReprValues(unittest.TestCase):
+    def test_named_expression(self) -> None:
+        @icontract.require(lambda x: (t := x + 1) and t > 1)  # pylint: disable=undefined-variable
+        def func(x: int) -> int:
+            return x
+
+        violation_err = None  # type: Optional[icontract.ViolationError]
+        try:
+            func(x=0)
+        except icontract.ViolationError as err:
+            violation_err = err
+
+        self.assertIsNotNone(violation_err)
+        self.assertEqual(
+            textwrap.dedent('''\
+                (t := x + 1) and t > 1:
+                t was 1
+                x was 0'''), tests.error.wo_mandatory_location(str(violation_err)))
+
+
+if __name__ == '__main__':
+    unittest.main()