Repository: Parquery/icontract

Issue:
Violating contract with f-string produces NotImplementedError
An f-string 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 text: text != f'{text}{text}')
def include(text: str) -> str:
    return text

def test_include_a() -> None:
    with raises(ViolationError):
        include('')

def test_include_b() -> None:
    assert include('foo') == 'foo'
```

```
$ 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() -> None:
        with raises(ViolationError):
>           include('')

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:403: in generate_message
    repr_vals = repr_values(
test-env/lib/python3.8/site-packages/icontract/_represent.py:350: 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:234: in visit_Compare
    comparators = [self.visit(node=comparator) for comparator in node.comparators]
test-env/lib/python3.8/site-packages/icontract/_recompute.py:234: in <listcomp>
    comparators = [self.visit(node=comparator) for comparator in node.comparators]
/nix/store/z65l1jqvxa58zzwwa3bvglb6asj4y8cv-python3-3.8.5/lib/python3.8/ast.py:363: in visit
    return visitor(node)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <icontract._recompute.Visitor object at 0x7f1794bf39a0>
node = <_ast.JoinedStr object at 0x7f1794bf3280>

    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.JoinedStr'> <_ast.JoinedStr object at 0x7f1794bf3280>

test-env/lib/python3.8/site-packages/icontract/_recompute.py:471: NotImplementedError
=========================== short test summary info ============================
FAILED test_foo.py::test_include_a - NotImplementedError: Unhandled recomputa...
========================= 1 failed, 1 passed in 0.31s ==========================
```

Comments:

Patch:
diff --git a/icontract/_recompute.py b/icontract/_recompute.py
index 00faac1..0427994 100644
--- a/icontract/_recompute.py
+++ b/icontract/_recompute.py
@@ -88,6 +88,44 @@ class Visitor(ast.NodeVisitor):
             self.recomputed_values[node] = node.value
             return node.value
 
+    if sys.version_info >= (3, 6):
+
+        def visit_FormattedValue(self, node: ast.FormattedValue) -> Any:
+            """Format the node value."""
+            fmt = ['{']
+            # See https://docs.python.org/3/library/ast.html#ast.FormattedValue for these
+            # constants
+            if node.conversion == -1:
+                pass
+            elif node.conversion == 115:
+                fmt.append('!s')
+            elif node.conversion == 114:
+                fmt.append('!r')
+            elif node.conversion == 97:
+                fmt.append('!a')
+            else:
+                raise NotImplementedError("Unhandled conversion of a formatted value node {!r}: {}".format(
+                    node, node.conversion))
+
+            if node.format_spec is not None:
+                fmt.append(":")
+
+                # The following assert serves only documentation purposes so that the code is easier to follow.
+                assert isinstance(node.format_spec, ast.JoinedStr)
+                fmt.append(self.visit(node.format_spec))
+
+            fmt.append('}')
+
+            recomputed_value = self.visit(node.value)
+            return ''.join(fmt).format(recomputed_value)
+
+        def visit_JoinedStr(self, node: ast.JoinedStr) -> Any:
+            """Visit the values and concatenate them."""
+            joined_str = ''.join(self.visit(value_node) for value_node in node.values)
+
+            self.recomputed_values[node] = joined_str
+            return joined_str
+
     # pylint: enable=no-member
 
     def visit_List(self, node: ast.List) -> List[Any]:
diff --git a/icontract/_represent.py b/icontract/_represent.py
index 934b052..f3fb8ee 100644
--- a/icontract/_represent.py
+++ b/icontract/_represent.py
@@ -55,6 +55,17 @@ class Visitor(ast.NodeVisitor):
         self.reprs = dict()  # type: MutableMapping[str, str]
         self._atok = atok
 
+    if sys.version_info >= (3, 6):
+        # pylint: disable=no-member
+        def visit_JoinedStr(self, node: ast.JoinedStr) -> None:
+            """Show the whole joined strings without descending into the values."""
+            if node in self._recomputed_values:
+                value = self._recomputed_values[node]
+
+                if _representable(value=value):
+                    text = self._atok.get_text(node)
+                    self.reprs[text] = value
+
     def visit_Name(self, node: ast.Name) -> None:
         """
         Resolve the name from the variable look-up and the built-ins.
diff --git a/precommit.py b/precommit.py
index 926809e..2e9fc99 100755
--- a/precommit.py
+++ b/precommit.py
@@ -57,14 +57,10 @@ 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"] + unittest_targets,
+         "-m", "unittest", "discover"],
         cwd=str(repo_root),
         env=env)
     # yapf: enable


Test patch:
diff --git a/tests_3_6/__init__.py b/tests_3_6/__init__.py
new file mode 100644
index 0000000..c706c20
--- /dev/null
+++ b/tests_3_6/__init__.py
@@ -0,0 +1,12 @@
+"""
+Test Python 3.6-specific features.
+
+For example, one such feature is literal string interpolation.
+"""
+
+import sys
+
+if sys.version_info < (3, 6):
+    def load_tests(loader, suite, pattern):  # pylint: disable=unused-argument
+        """Ignore all the tests for lower Python versions."""
+        return suite
diff --git a/tests_3_6/test_represent.py b/tests_3_6/test_represent.py
new file mode 100644
index 0000000..65e5c4c
--- /dev/null
+++ b/tests_3_6/test_represent.py
@@ -0,0 +1,130 @@
+#!/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
+import math
+from typing import Optional  # pylint: disable=unused-import
+
+import icontract._represent
+import tests.error
+import tests.mock
+
+
+class TestLiteralStringInterpolation(unittest.TestCase):
+    def test_plain_string(self) -> None:
+        @icontract.require(lambda x: f"something" == '')
+        def func(x: float) -> float:
+            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(
+            'f"something" == \'\': f"something" was \'something\'',
+            tests.error.wo_mandatory_location(str(violation_err)))
+
+    def test_simple_interpolation(self) -> None:
+        @icontract.require(lambda x: f"{x}" == '')
+        def func(x: float) -> float:
+            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(
+            'f"{x}" == \'\': f"{x}" was \'0\'',
+            tests.error.wo_mandatory_location(str(violation_err)))
+
+    def test_string_formatting(self) -> None:
+        @icontract.require(lambda x: f"{x!s}" == '')
+        def func(x: float) -> float:
+            return x
+
+        violation_err = None  # type: Optional[icontract.ViolationError]
+        try:
+            func(x=1.984)
+        except icontract.ViolationError as err:
+            violation_err = err
+
+        self.assertIsNotNone(violation_err)
+        self.assertEqual(
+            'f"{x!s}" == \'\': f"{x!s}" was \'1.984\'',
+            tests.error.wo_mandatory_location(str(violation_err)))
+
+    def test_repr_formatting(self) -> None:
+        @icontract.require(lambda x: f"{x!r}" == '')
+        def func(x: float) -> float:
+            return x
+
+        violation_err = None  # type: Optional[icontract.ViolationError]
+        try:
+            func(x=1.984)
+        except icontract.ViolationError as err:
+            violation_err = err
+
+        self.assertIsNotNone(violation_err)
+        self.assertEqual(
+            'f"{x!r}" == \'\': f"{x!r}" was \'1.984\'',
+            tests.error.wo_mandatory_location(str(violation_err)))
+
+    def test_ascii_formatting(self) -> None:
+        @icontract.require(lambda x: f"{x!a}" == '')
+        def func(x: float) -> float:
+            return x
+
+        violation_err = None  # type: Optional[icontract.ViolationError]
+        try:
+            func(x=1.984)
+        except icontract.ViolationError as err:
+            violation_err = err
+
+        self.assertIsNotNone(violation_err)
+        self.assertEqual(
+            'f"{x!a}" == \'\': f"{x!a}" was \'1.984\'',
+            tests.error.wo_mandatory_location(str(violation_err)))
+
+    def test_format_spec(self) -> None:
+        @icontract.require(lambda x: f"{x:.3}" == '')
+        def func(x: float) -> float:
+            return x
+
+        violation_err = None  # type: Optional[icontract.ViolationError]
+        try:
+            func(x=1.984)
+        except icontract.ViolationError as err:
+            violation_err = err
+
+        self.assertIsNotNone(violation_err)
+        self.assertEqual(
+            'f"{x:.3}" == \'\': f"{x:.3}" was \'1.98\'',
+            tests.error.wo_mandatory_location(str(violation_err)))
+
+    def test_conversion_and_format_spec(self) -> None:
+        @icontract.require(lambda x: f"{x!r:.3}" == '')
+        def func(x: float) -> float:
+            return x
+
+        violation_err = None  # type: Optional[icontract.ViolationError]
+        try:
+            func(x=1.984)
+        except icontract.ViolationError as err:
+            violation_err = err
+
+        self.assertIsNotNone(violation_err)
+        self.assertEqual(
+            'f"{x!r:.3}" == \'\': f"{x!r:.3}" was \'1.9\'',
+            tests.error.wo_mandatory_location(str(violation_err)))
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/tests_3_8/__init__.py b/tests_3_8/__init__.py
index 774c18e..040310e 100644
--- a/tests_3_8/__init__.py
+++ b/tests_3_8/__init__.py
@@ -5,3 +5,11 @@ 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.
 """
+
+import sys
+
+if sys.version_info < (3, 8):
+
+    def load_tests(loader, suite, pattern):  # pylint: disable=unused-argument
+        """Ignore all the tests for lower Python versions."""
+        return suite