Repository: Parquery/icontract

Issue:
Invariant on subclass of NamedTuple produces TypeError
An `invariant` decorator on a subclass of  `typing.NamedTuple` produces a `TypeError`.  Using Python 3.8.5 and icontract 2.3.7.

Example file `foo.py`:
``` python
from typing import NamedTuple
from icontract import invariant

@invariant(lambda self: self.first > 0)
class RightHalfPlanePoint(NamedTuple):
    first: int
    second: int

print(RightHalfPlanePoint(1, 0))
```

```
$ python foo.py 
Traceback (most recent call last):
  File "foo.py", line 9, in <module>
    print(RightHalfPlanePoint(1, 0))
  File "/tmp/foo/test-env/lib/python3.8/site-packages/icontract/_checkers.py", line 477, in wrapper
    result = func(*args, **kwargs)
TypeError: object.__init__() takes exactly one argument (the instance to initialize)
```

Disabling asssertions removes the error:
```
$ python -O foo.py 
RightHalfPlanePoint(first=1, second=0)
```

Comments:

Patch:
diff --git a/icontract/_checkers.py b/icontract/_checkers.py
index 83fa17b..90d4a44 100644
--- a/icontract/_checkers.py
+++ b/icontract/_checkers.py
@@ -441,6 +441,32 @@ def _find_self(param_names: List[str], args: Tuple[Any, ...], kwargs: Dict[str,
     return kwargs["self"]
 
 
+def _decorate_new_with_invariants(new_func: CallableT) -> CallableT:
+    """
+    Decorate the ``__new__`` of a class s.t. the invariants are checked on the result.
+
+    This is necessary for optimized classes such as ``namedtuple`` which use ``object.__init__``
+    as constructor and do not expect a wrapping around the constructor.
+    """
+    if _already_decorated_with_invariants(func=new_func):
+        return new_func
+
+    def wrapper(*args, **kwargs):  # type: ignore
+        """Pass the arguments to __new__ and check invariants on the result."""
+        instance = new_func(*args, **kwargs)
+
+        for contract in instance.__class__.__invariants__:
+            _assert_invariant(contract=contract, instance=instance)
+
+        return instance
+
+    functools.update_wrapper(wrapper=wrapper, wrapped=new_func)
+
+    setattr(wrapper, "__is_invariant_check__", True)
+
+    return wrapper  # type: ignore
+
+
 def _decorate_with_invariants(func: CallableT, is_init: bool) -> CallableT:
     """
     Decorate the function ``func`` of the class ``cls`` with invariant checks.
@@ -546,6 +572,8 @@ def _already_decorated_with_invariants(func: CallableT) -> bool:
 
 def add_invariant_checks(cls: type) -> None:
     """Decorate each of the class functions with invariant checks if not already decorated."""
+    # pylint: disable=too-many-branches
+
     # Candidates for the decoration as list of (name, dir() value)
     init_name_func = None  # type: Optional[Tuple[str, Callable[..., None]]]
     names_funcs = []  # type: List[Tuple[str, Callable[..., None]]]
@@ -590,8 +618,16 @@ def add_invariant_checks(cls: type) -> None:
 
     if init_name_func:
         name, func = init_name_func
-        wrapper = _decorate_with_invariants(func=func, is_init=True)
-        setattr(cls, name, wrapper)
+
+        # We have to distinguish this special case which is used by named
+        # tuples and possibly other optimized data structures.
+        # In those cases, we have to wrap __new__ instead of __init__.
+        if func == object.__init__ and hasattr(cls, "__new__"):
+            new_func = getattr(cls, "__new__")
+            setattr(cls, "__new__", _decorate_new_with_invariants(new_func))
+        else:
+            wrapper = _decorate_with_invariants(func=func, is_init=True)
+            setattr(cls, name, wrapper)
 
     for name, func in names_funcs:
         wrapper = _decorate_with_invariants(func=func, is_init=False)


Test patch:
diff --git a/tests/test_invariant.py b/tests/test_invariant.py
index e8e7a67..d47cf44 100644
--- a/tests/test_invariant.py
+++ b/tests/test_invariant.py
@@ -5,7 +5,7 @@
 
 import time
 import unittest
-from typing import Dict, Iterator, Mapping, Optional, Any  # pylint: disable=unused-import
+from typing import Dict, Iterator, Mapping, Optional, Any, NamedTuple  # pylint: disable=unused-import
 
 import icontract
 import tests.error
diff --git a/tests_3_8/test_invariant.py b/tests_3_8/test_invariant.py
new file mode 100644
index 0000000..ff2929a
--- /dev/null
+++ b/tests_3_8/test_invariant.py
@@ -0,0 +1,57 @@
+# pylint: disable=missing-docstring
+# pylint: disable=invalid-name
+# pylint: disable=unused-argument
+# pylint: disable=no-member
+import textwrap
+import unittest
+from typing import NamedTuple, Optional  # pylint: disable=unused-import
+
+import icontract
+import tests.error
+
+
+class TestOK(unittest.TestCase):
+    def test_on_named_tuple(self) -> None:
+        # This test is related to the issue #171.
+        #
+        # The test could not be executed under Python 3.6 as the ``inspect`` module
+        # could not figure out the type of getters.
+        @icontract.invariant(lambda self: self.first > 0)
+        class RightHalfPlanePoint(NamedTuple):
+            first: int
+            second: int
+
+        _ = RightHalfPlanePoint(1, 0)
+
+        self.assertEqual('Create new instance of RightHalfPlanePoint(first, second)',
+                         RightHalfPlanePoint.__new__.__doc__)
+
+
+class TestViolation(unittest.TestCase):
+    def test_on_named_tuple(self) -> None:
+        # This test is related to the issue #171.
+        #
+        # The test could not be executed under Python 3.6 as the ``inspect`` module
+        # could not figure out the type of getters.
+        @icontract.invariant(lambda self: self.second > 0)
+        @icontract.invariant(lambda self: self.first > 0)
+        class RightHalfPlanePoint(NamedTuple):
+            first: int
+            second: int
+
+        violation_error = None  # type: Optional[icontract.ViolationError]
+        try:
+            _ = RightHalfPlanePoint(1, -1)
+        except icontract.ViolationError as err:
+            violation_error = err
+
+        self.assertIsNotNone(violation_error)
+        self.assertEqual(
+            textwrap.dedent('''\
+                self.second > 0:
+                self was RightHalfPlanePoint(first=1, second=-1)
+                self.second was -1'''), tests.error.wo_mandatory_location(str(violation_error)))
+
+
+if __name__ == '__main__':
+    unittest.main()