Repository: Parquery/icontract (python)

Issue:
Feature request: immediate invariant evaluation.
Hi,

Very much taken by your library's functionality: much appreciated!

I would like to request an (perhaps optional?) feature for immediate evaluation of invariants (optional because, no doubt, this incurs a runtime penalty).

At the moment, as per [the documentation](https://icontract.readthedocs.io/en/latest/usage.html#invariants), invariants are evaluated when an object's method is called...
```python
from dataclasses import dataclass
import icontract

@icontract.invariant(lambda self: self.bar >= 0)
@dataclass
class Foo():
    bar:int = 0
    def invoke(self): pass

obj = Foo(bar=0)
obj.bar = -10   # <- Violates the contract.
obj.invoke()    # <- Contract evaluation.
```

...yielding the following (expected) exception:

```
.venvuser@localhost:~/home/user/test$ python3 test.py
Traceback (most recent call last):
  File "/home/user/test/test.py", line 12, in <module>
    obj.invoke()    # <- Contract evaluation.
    ^^^^^^^^^^^^
  File "/home/user/test/.venv/lib/python3.12/site-packages/icontract/_checkers.py", line 1133, in wrapper
    _assert_invariant(contract=contract, instance=instance)
  File "/home/user/test/.venv/lib/python3.12/site-packages/icontract/_checkers.py", line 530, in _assert_invariant
    raise _create_violation_error(
icontract.errors.ViolationError: File /home/user/test/test.py, line 4 in <module>:
self.bar >= 0:
self was Foo(bar=-10)
self.bar was -10
```

In comparison, the [Deal](https://pypi.org/project/deal/) package immediately evaluates the contract...
```python
from dataclasses import dataclass
import deal

@deal.inv(lambda self: self.bar >= 0)
@dataclass
class Foo():
    bar:int = 0
    def invoke(self): pass

obj = Foo(bar=0)
#print(f"obj type={type(obj)}")
obj.bar = -10   # <- Violates the contract, contract evaluation.
```

...yielding the following (expected) exception:
```
.venvuser@localhost:~/home/user/test$ python3 test.py
Traceback (most recent call last):
  File "/home/user/test/test.py", line 14, in <module>
    obj.bar = -10   # <- Violates the contract, contract evaluation.
    ^^^^^^^
deal.InvContractError: expected self.bar >= 0
```

Likewise, [Pydantic](https://pypi.org/project/pydantic/) can be persuaded (with the [validate_assignment](https://docs.pydantic.dev/latest/api/config/#pydantic.config.ConfigDict.validate_assignment) [configuration](https://docs.pydantic.dev/latest/api/config/#pydantic.config.ConfigDict) option) to immediate type hint evaluation...
```python
from pydantic.dataclasses import dataclass
from pydantic import ConfigDict

@dataclass(config=dict(validate_assignment=True))
class Foo():
    bar:int = 0
    def invoke(self): pass

obj = Foo(bar=0)
obj.bar = 'bar' # <- Violates type hint, type evaluation.
```

...yielding the following (expected) exception:
```
.venvuser@localhost:~/test$ python3 test.py
Traceback (most recent call last):
  File "/home/user/test/test.py", line 12, in <module>
    obj.bar = 'bar' # <- Violates type hint, type evaluation.
    ^^^^^^^
  File "/home/user/test/.venv/lib/python3.12/site-packages/pydantic/_internal/_dataclasses.py", line 189, in validated_setattr
    validator.validate_assignment(instance, field, value)
pydantic_core._pydantic_core.ValidationError: 1 validation error for Foo
bar
  Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='bar', input_type=str]
    For further information visit https://errors.pydantic.dev/2.7/v/int_parsing
```

Such added functionality would pursue me to use your library instead of Pydantic (which requires subclassing [BaseModel](https://docs.pydantic.dev/latest/api/base_model/) and either more convoluted [model](https://docs.pydantic.dev/latest/api/base_model/#pydantic.BaseModel.model_validate) or [Annotated](https://docs.pydantic.dev/latest/concepts/validators/) validation) or Deal (which performs, in my opinion, undesirable runtime subclassing):
```
obj type=<class 'deal._runtime._invariant.FooInvarianted'>
```

In the solution sphere (without knowing the internals of your code) perhaps [descriptor](https://docs.python.org/3/howto/descriptor.html#validator-class)s or [object-tracker](https://pypi.org/project/object-tracker/) could assist with such functionality?

Pull request description:
Allowed to enforce invariants on attribute setting
Originally, we had enforced invariants only at calls to "normal" methods, and excluded ``__setattr__`` since it is usually too expensive to verify invariants whenever setting an attribute.

However, there are use cases where the users prefer to incur to computational overhead for correctness. To that end, we introduced the feature in this patch to steer when the invariants are enforced (at method calls, on setting attributes, or in both situations).

Fixes #291.

Patch:
diff --git a/docs/source/api.rst b/docs/source/api.rst
index ed55e79..06e81de 100644
--- a/docs/source/api.rst
+++ b/docs/source/api.rst
@@ -45,3 +45,7 @@ DBC
 ViolationError
 --------------
 .. autoclass:: ViolationError
+
+InvariantCheckEvent
+-------------------
+.. autoclass:: InvariantCheckEvent
\ No newline at end of file
diff --git a/docs/source/usage.rst b/docs/source/usage.rst
index d51355b..6d187ec 100644
--- a/docs/source/usage.rst
+++ b/docs/source/usage.rst
@@ -113,7 +113,7 @@ Invariants
 ----------
 Invariants are special contracts associated with an instance of a class. An invariant should hold *after* initialization
 and *before* and *after* a call to any public instance method. The invariants are the pivotal element of
-design-by-contract: they allow you to formally define properties of a data structures that you know will be maintained
+design-by-contract: they allow you to formally define properties of data structures that you know will be maintained
 throughout the life time of *every* instance.
 
 We consider the following methods to be "public":
@@ -121,15 +121,37 @@ We consider the following methods to be "public":
 * All methods not prefixed with ``_``
 * All magic methods (prefix ``__`` and suffix ``__``)
 
-Class methods (marked with ``@classmethod`` or special dunders such as ``__new__``) can not observe the invariant
-since they are not associated with an instance of the class.
+Class methods (marked with ``@classmethod`` or special dunders such as ``__new__``) can not observe the invariants
+since they are not associated with an instance of the class. We also exempt ``__getattribute__`` method from observing
+the invariants since these functions alter the state of the instance and thus can not be considered "public".
+We exempt ``__repr__`` method as well to prevent endless loops when generating error messages.
+At runtime, many icontract-specific dunder attributes (such as ``__invariants__``) need to be accessed, so the method
+``__getattribute__`` can not be decorated lest we end up in an endless recursion.
 
-We exempt ``__getattribute__``, ``__setattr__`` and ``__delattr__`` methods from observing the invariant since
-these functions alter the state of the instance and thus can not be considered "public".
+By default, we do not enforce the invariants on calls to ``__setattr__`` as that is usually
+prohibitively expensive in terms of computation for most use cases. However, there is a parameter
+``check_on`` to an :class:`invariant` which allows you to steer in a more fine-grained manner when the invariant should
+be enforced.
 
-We also exempt ``__repr__`` method to prevent endless loops when generating error messages.
+.. note::
 
-The icontract invariants are implemented as class decorators.
+    Be careful with instance attributes referencing other instances or collections. For example, ``a.some_list.append(3)``
+    will not trigger the check of invariants as the attribute ``a.some_list``, kept as a reference, remains unchanged.
+    That is, even though the referenced object changes (the actual list), the reference does not.
+
+The default value of ``check_on`` is set to :attr:`InvariantCheckEvent.CALL`, meaning that we check
+the invariants only in the calls to the methods *excluding* ``__setattr__``. If you want to check
+the invariants *only* on ``__setattr__`` and excluding *any* other method, set it to :attr:`InvariantCheckEvent.SETATTR`.
+The combinations is also possible; to check invariants on method calls *including* ``__setattr__``, set ``check_on`` to
+:attr:`InvariantCheckEvent.CALL` ``|`` :attr:`InvariantCheckEvent.SETATTR`.
+
+To save you some typing, we introduced the shortcut, :attr:`InvariantCheckEvent.ALL`, which stands for the combination
+:attr:`InvariantCheckEvent.CALL` ``|`` :attr:`InvariantCheckEvent.SETATTR`.
+
+.. note::
+
+	The property getters and setters are considered "normal" methods. If you want to check the invariants at property
+	getters and/or setters, make sure to include :attr:`InvariantCheckEvent.CALL` in ``check_on``.
 
 The following examples show various cases when an invariant is breached.
 
@@ -229,6 +251,45 @@ After the invocation of a magic method:
     self was an instance of SomeClass
     self.x was -1
 
+Enforcing the invariants on the method calls *including* ``__setattr__``:
+
+.. code-block:: python
+
+    >>> @icontract.invariant(
+    ...     lambda self: self.x > 0,
+    ...        check_on=(
+    ...            icontract.InvariantCheckEvent.CALL
+    ...            | icontract.InvariantCheckEvent.SETATTR
+    ...			)
+    ... )
+    ... class SomeClass:
+    ...     def __init__(self) -> None:
+    ...         self.x = 100
+    ...
+    ...     def do_something_bad(self) -> None:
+    ...         self.x = -1
+    ...
+    ...     def __repr__(self) -> str:
+    ...         return "an instance of SomeClass"
+    ...
+    >>> some_instance = SomeClass()
+    >>> some_instance.do_something_bad()
+    Traceback (most recent call last):
+     ...
+    icontract.errors.ViolationError: File <doctest usage.rst[26]>, line 1 in <module>:
+    self.x > 0:
+    self was an instance of SomeClass
+    self.x was -1
+
+    >>> another_instance = SomeClass()
+    >>> another_instance.x = -1
+    Traceback (most recent call last):
+     ...
+    icontract.errors.ViolationError: File <doctest usage.rst[26]>, line 1 in <module>:
+    self.x > 0:
+    self was an instance of SomeClass
+    self.x was -1
+
 Snapshots (a.k.a "old" argument values)
 ---------------------------------------
 Usual postconditions can not verify the state transitions of the function's argument values. For example, it is
@@ -261,7 +322,7 @@ Here is an example that uses snapshots to check that a value was appended to the
     >>> some_func(lst=[1, 2], value=3)
     Traceback (most recent call last):
         ...
-    icontract.errors.ViolationError: File <doctest usage.rst[28]>, line 2 in <module>:
+    icontract.errors.ViolationError: File <doctest usage.rst[33]>, line 2 in <module>:
     lst == OLD.lst + [value]:
     OLD was a bunch of OLD values
     OLD.lst was [1, 2]
@@ -285,7 +346,7 @@ The following example shows how you can name the snapshot:
     >>> some_func(lst=[1, 2], value=3)
     Traceback (most recent call last):
         ...
-    icontract.errors.ViolationError: File <doctest usage.rst[32]>, line 2 in <module>:
+    icontract.errors.ViolationError: File <doctest usage.rst[37]>, line 2 in <module>:
     len(lst) == OLD.len_lst + 1:
     OLD was a bunch of OLD values
     OLD.len_lst was 2
@@ -311,7 +372,7 @@ The next code snippet shows how you can combine multiple arguments of a function
     >>> some_func(lst_a=[1, 2], lst_b=[3, 4])  # doctest: +ELLIPSIS
     Traceback (most recent call last):
         ...
-    icontract.errors.ViolationError: File <doctest usage.rst[36]>, line ... in <module>:
+    icontract.errors.ViolationError: File <doctest usage.rst[...]>, line ... in <module>:
     set(lst_a).union(lst_b) == OLD.union:
     OLD was a bunch of OLD values
     OLD.union was {1, 2, 3, 4}
@@ -394,7 +455,7 @@ The following example shows an abstract parent class and a child class that inhe
         >>> some_b.func(y=0)
         Traceback (most recent call last):
             ...
-        icontract.errors.ViolationError: File <doctest usage.rst[40]>, line 7 in A:
+        icontract.errors.ViolationError: File <doctest usage.rst[45]>, line 7 in A:
         result < y:
         result was 1
         self was an instance of B
@@ -405,7 +466,7 @@ The following example shows an abstract parent class and a child class that inhe
         >>> another_b.break_parent_invariant()
         Traceback (most recent call last):
             ...
-        icontract.errors.ViolationError: File <doctest usage.rst[40]>, line 1 in <module>:
+        icontract.errors.ViolationError: File <doctest usage.rst[45]>, line 1 in <module>:
         self.x > 0:
         self was an instance of B
         self.x was -1
@@ -415,7 +476,7 @@ The following example shows an abstract parent class and a child class that inhe
         >>> yet_another_b.break_my_invariant()
         Traceback (most recent call last):
             ...
-        icontract.errors.ViolationError: File <doctest usage.rst[41]>, line 1 in <module>:
+        icontract.errors.ViolationError: File <doctest usage.rst[46]>, line 1 in <module>:
         self.x < 100:
         self was an instance of B
         self.x was 101
@@ -453,7 +514,7 @@ The following example shows how preconditions are weakened:
         >>> b.func(x=5)
         Traceback (most recent call last):
             ...
-        icontract.errors.ViolationError: File <doctest usage.rst[49]>, line 2 in B:
+        icontract.errors.ViolationError: File <doctest usage.rst[54]>, line 2 in B:
         x % 3 == 0:
         self was an instance of B
         x was 5
@@ -484,7 +545,7 @@ The example below illustrates how snapshots are inherited:
         >>> b.func(lst=[1, 2], value=3)
         Traceback (most recent call last):
             ...
-        icontract.errors.ViolationError: File <doctest usage.rst[54]>, line 4 in A:
+        icontract.errors.ViolationError: File <doctest usage.rst[59]>, line 4 in A:
         len(lst) == len(OLD.lst) + 1:
         OLD was a bunch of OLD values
         OLD.lst was [1, 2]
@@ -495,7 +556,6 @@ The example below illustrates how snapshots are inherited:
         self was an instance of B
         value was 3
 
-
 Toggling Contracts
 ------------------
 By default, the contract checks (including the snapshots) are always performed at run-time. To disable them, run the
@@ -607,7 +667,7 @@ Here is an example of the error given as a subclass of `BaseException`_:
     >>> some_func(x=0)
     Traceback (most recent call last):
         ...
-    ValueError: File <doctest usage.rst[62]>, line 1 in <module>:
+    ValueError: File <doctest usage.rst[67]>, line 1 in <module>:
     x > 0: x was 0
 
 Here is an example of the error given as an instance of a `BaseException`_:
diff --git a/icontract/__init__.py b/icontract/__init__.py
index 4dfd61f..3919416 100644
--- a/icontract/__init__.py
+++ b/icontract/__init__.py
@@ -48,3 +48,5 @@ _Snapshot = icontract._types.Snapshot
 import icontract.errors
 
 ViolationError = icontract.errors.ViolationError
+
+InvariantCheckEvent = icontract._types.InvariantCheckEvent
diff --git a/icontract/_checkers.py b/icontract/_checkers.py
index 513d52c..efb445f 100644
--- a/icontract/_checkers.py
+++ b/icontract/_checkers.py
@@ -18,9 +18,10 @@ from typing import (
 
 import icontract._represent
 from icontract._globals import CallableT, ClassT
-from icontract._types import Contract, Snapshot
+from icontract._types import Contract, Snapshot, InvariantCheckEvent
 from icontract.errors import ViolationError
 
+
 # pylint does not play with typing.Mapping.
 # pylint: disable=unsubscriptable-object
 # pylint: disable=raising-bad-type
@@ -965,8 +966,8 @@ def _decorate_new_with_invariants(new_func: CallableT) -> CallableT:
         """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)
+        for invariant in instance.__class__.__invariants__:
+            _assert_invariant(contract=invariant, instance=instance)
 
         return instance
 
@@ -979,7 +980,7 @@ def _decorate_new_with_invariants(new_func: CallableT) -> CallableT:
 
 def _decorate_with_invariants(func: CallableT, is_init: bool) -> CallableT:
     """
-    Decorate the function ``func`` of the class ``cls`` with invariant checks.
+    Decorate the method ``func`` with invariant checks.
 
     If the function has been already decorated with invariant checks, the function returns immediately.
 
@@ -1025,8 +1026,8 @@ def _decorate_with_invariants(func: CallableT, is_init: bool) -> CallableT:
             try:
                 result = func(*args, **kwargs)
 
-                for contract in instance.__class__.__invariants__:
-                    _assert_invariant(contract=contract, instance=instance)
+                for invariant in instance.__class__.__invariants__:
+                    _assert_invariant(contract=invariant, instance=instance)
 
                 return result
             finally:
@@ -1060,6 +1061,12 @@ def _decorate_with_invariants(func: CallableT, is_init: bool) -> CallableT:
                         ).format(func, param_names, args, kwargs)
                     ) from err
 
+                invariants = (
+                    instance.__class__.__invariants_on_setattr__
+                    if func.__name__ == "__setattr__"
+                    else instance.__class__.__invariants_on_call__
+                )
+
                 # We need to create a new in-progress set if it is None as the ``ContextVar`` does not accept
                 # a factory function for the default argument. If we didn't do this, and simply set an empty
                 # set as the default, ``ContextVar`` would always point to the same set by copying the default
@@ -1080,13 +1087,13 @@ def _decorate_with_invariants(func: CallableT, is_init: bool) -> CallableT:
 
                 # ExitStack is not used here due to performance.
                 try:
-                    for contract in instance.__class__.__invariants__:
-                        _assert_invariant(contract=contract, instance=instance)
+                    for invariant in invariants:
+                        _assert_invariant(contract=invariant, instance=instance)
 
                     result = await func(*args, **kwargs)
 
-                    for contract in instance.__class__.__invariants__:
-                        _assert_invariant(contract=contract, instance=instance)
+                    for invariant in invariants:
+                        _assert_invariant(contract=invariant, instance=instance)
 
                     return result
                 finally:
@@ -1108,6 +1115,12 @@ def _decorate_with_invariants(func: CallableT, is_init: bool) -> CallableT:
                         ).format(func, param_names, args, kwargs)
                     ) from err
 
+                invariants = (
+                    instance.__class__.__invariants_on_setattr__
+                    if func.__name__ == "__setattr__"
+                    else instance.__class__.__invariants_on_call__
+                )
+
                 # The following dunder indicates whether another invariant is currently being checked. If so,
                 # we need to suspend any further invariant check to avoid endless recursion.
 
@@ -1129,13 +1142,13 @@ def _decorate_with_invariants(func: CallableT, is_init: bool) -> CallableT:
 
                 # ExitStack is not used here due to performance.
                 try:
-                    for contract in instance.__class__.__invariants__:
-                        _assert_invariant(contract=contract, instance=instance)
+                    for invariant in invariants:
+                        _assert_invariant(contract=invariant, instance=instance)
 
                     result = func(*args, **kwargs)
 
-                    for contract in instance.__class__.__invariants__:
-                        _assert_invariant(contract=contract, instance=instance)
+                    for invariant in invariants:
+                        _assert_invariant(contract=invariant, instance=instance)
 
                     return result
                 finally:
@@ -1169,25 +1182,38 @@ def _already_decorated_with_invariants(func: CallableT) -> bool:
 def add_invariant_checks(cls: ClassT) -> None:
     """Decorate each of the class functions with invariant checks if not already decorated."""
     # Candidates for the decoration as list of (name, dir() value)
-    init_name_func = None  # type: Optional[Tuple[str, Callable[..., None]]]
+    init_func = None  # type: Optional[Callable[..., None]]
     names_funcs = []  # type: List[Tuple[str, Callable[..., None]]]
     names_properties = []  # type: List[Tuple[str, property]]
 
-    # Filter out entries in the directory which are certainly not candidates for decoration.
+    # As we continuously decorate the class with invariants, we never definitely know
+    # whether this decoration is the last one. Hence, we can only retrieve the list
+    # of invariants decorated *thus far*. As we only add one invariant at the time,
+    # we only need to check for the last invariant.
+    assert cls.__invariants__ is not None, (  # type: ignore
+        "Expected to set ``__invariants__`` in the invariant decorator before "
+        "the call to {}".format(add_invariant_checks.__name__)
+    )
+    assert len(cls.__invariants__) > 0, (  # type: ignore
+        "Expected at least one invariant in the ``__invariants__`` since we expect "
+        "to push the latest invariant in the invariant decorator before the call to "
+        "{}".format(add_invariant_checks.__name__)
+    )
+    last_invariant = cls.__invariants__[-1]  # type: ignore
+    assert isinstance(last_invariant, icontract._types.Invariant)
+
+    # Filter out entries in the directory which are certainly not candidates for decoration
+    # regarding the ``last_invariant``. Note that the functions which are already decorated
+    # will not be re-decorated, so that this loop runs in O( dir(cls) * len(invariants) ),
+    # but with a negligible constant.
     for name in dir(cls):
         value = getattr(cls, name)
 
         # __new__ is a special class method (though not marked properly with @classmethod!).
         # We need to ignore __repr__ to prevent endless loops when generating error messages.
-        # __getattribute__, __setattr__ and __delattr__ are too invasive and alter the state of the instance.
-        # Hence we don't consider them "public".
-        if name in [
-            "__new__",
-            "__repr__",
-            "__getattribute__",
-            "__setattr__",
-            "__delattr__",
-        ]:
+        # We also need to ignore __getattribute__ since pretty much any operation on the instance
+        # will result in an endless loop.
+        if name in ["__new__", "__repr__", "__getattribute__"]:
             continue
 
         if name == "__init__":
@@ -1197,7 +1223,19 @@ def add_invariant_checks(cls: ClassT) -> None:
                 type(value)
             )
 
-            init_name_func = (name, value)
+            init_func = value
+            continue
+
+        if (
+            name != "__setattr__"
+            and InvariantCheckEvent.CALL not in last_invariant.check_on
+        ):
+            continue
+
+        if (
+            name == "__setattr__"
+            and InvariantCheckEvent.SETATTR not in last_invariant.check_on
+        ):
             continue
 
         if (
@@ -1234,18 +1272,16 @@ def add_invariant_checks(cls: ClassT) -> None:
                 )
             )
 
-    if init_name_func:
-        name, func = init_name_func
-
+    if init_func:
         # 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__"):
+        if init_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)
+            wrapper = _decorate_with_invariants(func=init_func, is_init=True)
+            setattr(cls, init_func.__name__, wrapper)
 
     for name, func in names_funcs:
         wrapper = _decorate_with_invariants(func=func, is_init=False)
diff --git a/icontract/_decorators.py b/icontract/_decorators.py
index d5014c6..c7b0ecd 100644
--- a/icontract/_decorators.py
+++ b/icontract/_decorators.py
@@ -13,7 +13,7 @@ from typing import (
 
 import icontract._checkers
 from icontract._globals import CallableT, ExceptionT, ClassT
-from icontract._types import Contract, Snapshot
+from icontract._types import Contract, Snapshot, InvariantCheckEvent, Invariant
 
 
 class require:  # pylint: disable=invalid-name
@@ -350,6 +350,9 @@ class invariant:  # pylint: disable=invalid-name
     async, as most dunder methods need to be synchronous methods, and wrapping them with async code would
     break that constraint.
 
+    For efficiency reasons, we do not check the invariants at the calls to ``__setattr__`` and ``__getattr__``
+    methods by default. If you indeed want to check the invariants in those calls as well, make sure to adapt
+    the argument ``check_on`` accordingly.
     """
 
     def __init__(
@@ -361,6 +364,7 @@ class invariant:  # pylint: disable=invalid-name
         error: Optional[
             Union[Callable[..., ExceptionT], Type[ExceptionT], BaseException]
         ] = None,
+        check_on: InvariantCheckEvent = InvariantCheckEvent.CALL,
     ) -> None:
         """
         Initialize a class decorator to establish the invariant on all the public methods.
@@ -387,11 +391,17 @@ class invariant:  # pylint: disable=invalid-name
             * A subclass of ``BaseException`` which is instantiated with the violation message and raised
               on contract violation.
             * An instance of ``BaseException`` that will be raised with the traceback on contract violation.
+        :param check_on:
+            Specify when to check the invariant.
+
+            * If :py:attr:`InvariantCheckEvent.CALL` is set, the invariant will be checked on all the method calls
+              except ``__setattr__``.
+            * If :py:attr:`InvariantCheckEvent.SETATTR` is set, the invariant will be checked on ``__setattr__`` calls.
         :return:
 
         """
         self.enabled = enabled
-        self._contract = None  # type: Optional[Contract]
+        self._invariant = None  # type: Optional[Invariant]
 
         if not enabled:
             return
@@ -432,7 +442,8 @@ class invariant:  # pylint: disable=invalid-name
                 "Async conditions are not possible in invariants as sync methods such as __init__ have to be wrapped."
             )
 
-        self._contract = Contract(
+        self._invariant = Invariant(
+            check_on=check_on,
             condition=condition,
             description=description,
             a_repr=a_repr,
@@ -440,10 +451,12 @@ class invariant:  # pylint: disable=invalid-name
             location=location,
         )
 
-        if self._contract.mandatory_args and self._contract.mandatory_args != ["self"]:
+        if self._invariant.mandatory_args and self._invariant.mandatory_args != [
+            "self"
+        ]:
             raise ValueError(
                 "Expected an invariant condition with at most an argument 'self', but got: {}".format(
-                    self._contract.condition_args
+                    self._invariant.condition_args
                 )
             )
 
@@ -459,12 +472,18 @@ class invariant:  # pylint: disable=invalid-name
             return cls
 
         assert (
-            self._contract is not None
+            self._invariant is not None
         ), "self._contract must be set if the contract was enabled."
 
         if not hasattr(cls, "__invariants__"):
-            invariants = []  # type: List[Contract]
+            invariants = []  # type: List[Invariant]
             setattr(cls, "__invariants__", invariants)
+
+            invariants_on_call = []  # type: List[Invariant]
+            setattr(cls, "__invariants_on_call__", invariants_on_call)
+
+            invariants_on_setattr = []  # type: List[Invariant]
+            setattr(cls, "__invariants_on_setattr__", invariants_on_setattr)
         else:
             invariants = getattr(cls, "__invariants__")
             assert isinstance(
@@ -473,7 +492,25 @@ class invariant:  # pylint: disable=invalid-name
                 cls, type(invariants)
             )
 
-        invariants.append(self._contract)
+            invariants_on_call = getattr(cls, "__invariants_on_call__")
+            assert isinstance(invariants_on_call, list), (
+                "Expected invariants on call of class {} to be a list, "
+                "but got: {}".format(cls, type(invariants_on_call))
+            )
+
+            invariants_on_setattr = getattr(cls, "__invariants_on_setattr__")
+            assert isinstance(invariants_on_setattr, list), (
+                "Expected invariants on call of class {} to be a list, "
+                "but got: {}".format(cls, type(invariants_on_setattr))
+            )
+
+        invariants.append(self._invariant)
+
+        if InvariantCheckEvent.CALL in self._invariant.check_on:
+            invariants_on_call.append(self._invariant)
+
+        if InvariantCheckEvent.SETATTR in self._invariant.check_on:
+            invariants_on_setattr.append(self._invariant)
 
         icontract._checkers.add_invariant_checks(cls=cls)
 
diff --git a/icontract/_types.py b/icontract/_types.py
index 3e96bfb..727be67 100644
--- a/icontract/_types.py
+++ b/icontract/_types.py
@@ -1,4 +1,5 @@
 """Define data structures shared among the modules."""
+import enum
 import inspect
 import reprlib
 from typing import (
@@ -122,3 +123,48 @@ class Snapshot:
         self.arg_set = set(args)
 
         self.location = location
+
+
+class InvariantCheckEvent(enum.Flag):
+    """Define when an invariant should be checked."""
+
+    #: Evaluate the invariant before and after all calls to a method.
+    CALL = enum.auto()
+
+    #: Evaluate the invariant before and after all the calls to ``__setattr__``.
+    SETATTR = enum.auto()
+
+    #: Always evaluate the invariant, *i.e., both on calls and on attributes set.
+    ALL = CALL | SETATTR
+
+
+class Invariant(Contract):
+    """Represent a contract which is checked on all or some of the class operations."""
+
+    # NOTE (mristin):
+    # The class ``Invariant`` inherits from ``Contract`` so that we can maintain
+    # the backwards compatibility with the integrators after introducing
+    # the ``check_on`` feature.
+
+    def __init__(
+        self,
+        check_on: InvariantCheckEvent,
+        condition: Callable[..., Any],
+        description: Optional[str] = None,
+        a_repr: reprlib.Repr = icontract._globals.aRepr,
+        error: Optional[
+            Union[Callable[..., ExceptionT], Type[ExceptionT], BaseException]
+        ] = None,
+        location: Optional[str] = None,
+    ) -> None:
+        """Initialize with the given values."""
+        assert not hasattr(self, "check_on")
+        self.check_on = check_on
+
+        super().__init__(
+            condition=condition,
+            description=description,
+            a_repr=a_repr,
+            error=error,
+            location=location,
+        )
diff --git a/precommit.py b/precommit.py
index b0bb010..1c7bf5c 100755
--- a/precommit.py
+++ b/precommit.py
@@ -131,14 +131,21 @@ def main() -> int:
 
     subprocess.check_call(["coverage", "report"])
 
-    print("Doctesting...")
-    doc_files = ["README.rst"]
-    for pth in (repo_root / "docs" / "source").glob("**/*.rst"):
-        doc_files.append(str(pth.relative_to(repo_root)))
-    subprocess.check_call([sys.executable, "-m", "doctest"] + doc_files)
-
-    for pth in (repo_root / "icontract").glob("**/*.py"):
-        subprocess.check_call([sys.executable, "-m", "doctest", str(pth)])
+    if (3, 8) <= sys.version_info < (3, 9):
+        print("Doctesting...")
+        doc_files = ["README.rst"]
+        for pth in (repo_root / "docs" / "source").glob("**/*.rst"):
+            doc_files.append(str(pth.relative_to(repo_root)))
+        subprocess.check_call([sys.executable, "-m", "doctest"] + doc_files)
+
+        for pth in (repo_root / "icontract").glob("**/*.py"):
+            subprocess.check_call([sys.executable, "-m", "doctest", str(pth)])
+    else:
+        print(
+            "We pin the doctests at Python 3.8 as the output of the exception "
+            "traceback changes between the Python versions. You are running Python "
+            "{}, so we will not run the doctests.".format(sys.version_info)
+        )
 
     print("Checking the restructured text of the readme...")
     subprocess.check_call(
diff --git a/pylint.rc b/pylint.rc
index 6ce99e7..6e41e58 100644
--- a/pylint.rc
+++ b/pylint.rc
@@ -7,5 +7,5 @@ generated-members=bottle\.request\.forms\.decode,bottle\.request\.query\.decode
 max-line-length=120
 
 [MESSAGES CONTROL]
-disable=too-few-public-methods,len-as-condition,duplicate-code,no-else-raise,too-many-locals,too-many-branches,too-many-lines,too-many-arguments,too-many-statements,too-many-nested-blocks,too-many-function-args,too-many-instance-attributes,too-many-public-methods,protected-access,consider-using-in,no-member,consider-using-f-string,use-dict-literal,redundant-keyword-arg
+disable=too-few-public-methods,len-as-condition,duplicate-code,no-else-raise,too-many-locals,too-many-branches,too-many-lines,too-many-arguments,too-many-statements,too-many-nested-blocks,too-many-function-args,too-many-instance-attributes,too-many-public-methods,protected-access,consider-using-in,no-member,consider-using-f-string,use-dict-literal,redundant-keyword-arg,no-else-return
 


Test patch:
diff --git a/tests/test_invariant.py b/tests/test_invariant.py
index 38dd08b..9c8821d 100644
--- a/tests/test_invariant.py
+++ b/tests/test_invariant.py
@@ -10,6 +10,7 @@ from typing import (
     Mapping,
     Optional,
     Any,
+    List,
 )  # pylint: disable=unused-import
 
 import icontract
@@ -909,5 +910,408 @@ class TestInvalid(unittest.TestCase):
         )
 
 
+class TestCheckOn(unittest.TestCase):
+    def test_invariant_checked_in_init_if_no_flag_set(self) -> None:
+        # The check_on default is expected to be ``CALL`` here.
+        @icontract.invariant(lambda self: self.x > 0)
+        class A:
+            def __init__(self) -> None:
+                self.x = -1
+
+            def __repr__(self) -> str:
+                return "an instance of {}".format(self.__class__.__name__)
+
+        violation_error = None  # type: Optional[icontract.ViolationError]
+        try:
+            _ = A()
+        except icontract.ViolationError as err:
+            violation_error = err
+
+        self.assertIsNotNone(violation_error)
+        self.assertEqual(
+            textwrap.dedent(
+                """\
+                self.x > 0:
+                self was an instance of A
+                self.x was -1"""
+            ),
+            tests.error.wo_mandatory_location(str(violation_error)),
+        )
+
+    def test_invariant_checked_in_init_if_call_flag_set(self) -> None:
+        @icontract.invariant(
+            lambda self: self.x > 0, check_on=icontract.InvariantCheckEvent.CALL
+        )
+        class A:
+            def __init__(self) -> None:
+                self.x = -1
+
+            def __repr__(self) -> str:
+                return "an instance of {}".format(self.__class__.__name__)
+
+        violation_error = None  # type: Optional[icontract.ViolationError]
+        try:
+            _ = A()
+        except icontract.ViolationError as err:
+            violation_error = err
+
+        self.assertIsNotNone(violation_error)
+        self.assertEqual(
+            textwrap.dedent(
+                """\
+                self.x > 0:
+                self was an instance of A
+                self.x was -1"""
+            ),
+            tests.error.wo_mandatory_location(str(violation_error)),
+        )
+
+    def test_invariant_checked_in_init_if_setattr_flag_set(self) -> None:
+        @icontract.invariant(
+            lambda self: self.x > 0, check_on=icontract.InvariantCheckEvent.SETATTR
+        )
+        class A:
+            def __init__(self, an_x: int) -> None:
+                # NOTE (mristin):
+                # Setting the ``x`` will trigger a violation here.
+                self.x = an_x
+
+            def __repr__(self) -> str:
+                return "an instance of {}".format(self.__class__.__name__)
+
+        violation_error = None  # type: Optional[icontract.ViolationError]
+        try:
+            _ = A(-1)
+        except icontract.ViolationError as err:
+            violation_error = err
+
+        self.assertIsNotNone(violation_error)
+        self.assertEqual(
+            textwrap.dedent(
+                """\
+                self.x > 0:
+                self was an instance of A
+                self.x was -1"""
+            ),
+            tests.error.wo_mandatory_location(str(violation_error)),
+        )
+
+    def test_invariant_checked_in_init_if_all_flag_set(self) -> None:
+        @icontract.invariant(
+            lambda self: self.x > 0, check_on=icontract.InvariantCheckEvent.ALL
+        )
+        class A:
+            def __init__(self) -> None:
+                self.x = -1
+
+            def __repr__(self) -> str:
+                return "an instance of {}".format(self.__class__.__name__)
+
+        violation_error = None  # type: Optional[icontract.ViolationError]
+        try:
+            _ = A()
+        except icontract.ViolationError as err:
+            violation_error = err
+
+        self.assertIsNotNone(violation_error)
+        self.assertEqual(
+            textwrap.dedent(
+                """\
+                self.x > 0:
+                self was an instance of A
+                self.x was -1"""
+            ),
+            tests.error.wo_mandatory_location(str(violation_error)),
+        )
+
+    def test_setattr_not_affected_when_flag_not_set(self) -> None:
+        # The check_on default is expected to be ``CALL`` here.
+        @icontract.invariant(lambda self: self.x > 0)
+        class A:
+            def __init__(self) -> None:
+                self.x = 10
+
+        a = A()
+        a.x = -1
+
+        # No error expected here as we rely on the default ``check_on`` to be set to ``CALL``
+        # so that setattr is not affected.
+
+    def test_call_not_affected_when_flag_not_set(self) -> None:
+        # fmt: off
+        @icontract.invariant(
+            lambda self: self.x > 0,
+            check_on=icontract.InvariantCheckEvent.SETATTR
+        )
+        # fmt: on
+        class A:
+            def __init__(self) -> None:
+                self.x = 10
+
+            def do_something_bad(self) -> None:
+                self.__dict__["x"] = -1
+
+        a = A()
+        a.do_something_bad()
+
+        # No error expected here as ``check_on`` mandates to only check setting
+        # an attribute.
+
+    def test_setattr_affected_when_flag_set(self) -> None:
+        @icontract.invariant(
+            lambda self: self.x > 0, check_on=icontract.InvariantCheckEvent.SETATTR
+        )
+        class A:
+            def __init__(self) -> None:
+                self.x = 10
+
+            def __repr__(self) -> str:
+                return "an instance of {}".format(self.__class__.__name__)
+
+        a = A()
+
+        violation_error = None  # type: Optional[icontract.ViolationError]
+        try:
+            a.x = -1
+        except icontract.ViolationError as err:
+            violation_error = err
+
+        self.assertIsNotNone(violation_error)
+        self.assertEqual(
+            textwrap.dedent(
+                """\
+                self.x > 0:
+                self was an instance of A
+                self.x was -1"""
+            ),
+            tests.error.wo_mandatory_location(str(violation_error)),
+        )
+
+    def test_call_and_setattr_affected_when_flags_set(self) -> None:
+        @icontract.invariant(
+            lambda self: self.x > 0,
+            check_on=(
+                icontract.InvariantCheckEvent.CALL
+                | icontract.InvariantCheckEvent.SETATTR
+            ),
+        )
+        class A:
+            def __init__(self) -> None:
+                self.x = 10
+
+            def do_something_bad(self) -> None:
+                self.x = -1
+
+            def __repr__(self) -> str:
+                return "an instance of {}".format(self.__class__.__name__)
+
+        that = A()
+
+        violation_error = None  # type: Optional[icontract.ViolationError]
+        try:
+            that.x = -1
+        except icontract.ViolationError as err:
+            violation_error = err
+
+        self.assertIsNotNone(violation_error)
+        self.assertEqual(
+            textwrap.dedent(
+                """\
+                self.x > 0:
+                self was an instance of A
+                self.x was -1"""
+            ),
+            tests.error.wo_mandatory_location(str(violation_error)),
+        )
+
+        other = A()
+
+        another_violation_error = None  # type: Optional[icontract.ViolationError]
+        try:
+            other.do_something_bad()
+        except icontract.ViolationError as err:
+            another_violation_error = err
+
+        self.assertIsNotNone(another_violation_error)
+        self.assertEqual(
+            textwrap.dedent(
+                """\
+                self.x > 0:
+                self was an instance of A
+                self.x was -1"""
+            ),
+            tests.error.wo_mandatory_location(str(another_violation_error)),
+        )
+
+    def test_setattr_with_property(self) -> None:
+        @icontract.invariant(
+            lambda self: self.x > 0, check_on=icontract.InvariantCheckEvent.SETATTR
+        )
+        class A:
+            def __init__(self) -> None:
+                self._x = 10
+
+            def __repr__(self) -> str:
+                return "an instance of {}".format(self.__class__.__name__)
+
+            @property
+            def x(self) -> int:
+                return self._x
+
+            @x.setter
+            def x(self, value: int) -> None:
+                self._x = value
+
+        a = A()
+
+        violation_error = None  # type: Optional[icontract.ViolationError]
+        try:
+            a.x = -1
+        except icontract.ViolationError as err:
+            violation_error = err
+
+        self.assertIsNotNone(violation_error)
+        self.assertEqual(
+            textwrap.dedent(
+                """\
+                self.x > 0:
+                self was an instance of A
+                self.x was -1"""
+            ),
+            tests.error.wo_mandatory_location(str(violation_error)),
+        )
+
+    def test_that_setattr_does_not_trigger_on_a_collection(self) -> None:
+        # NOTE (mristin):
+        # We want to explicitly specify that operations on collection attributes
+        # do not trigger the SETATTR event.
+
+        @icontract.invariant(
+            lambda self: len(self.lst) < 2,
+            check_on=icontract.InvariantCheckEvent.SETATTR,
+        )
+        class A:
+            def __init__(self) -> None:
+                self.lst = [3]
+
+        a = A()
+        # NOTE (mristin):
+        # There is no ``__setattr__`` call involved here, so the invariant will not be
+        # checked.
+        a.lst.append(10)
+
+    def test_that_invariant_checked_on_call_even_though_violated_through_leaking(
+        self,
+    ) -> None:
+        # NOTE (mristin):
+        # Example adapted from a comment in:
+        # https://github.com/Parquery/icontract/pull/292
+
+        @icontract.invariant(
+            lambda self: all(item < 0 for item in self.lst),
+            check_on=icontract.InvariantCheckEvent.CALL,
+        )
+        class A:
+            def __init__(self, lst: List[int]) -> None:
+                self.lst = lst
+
+            def do_something(self) -> None:
+                pass
+
+            def __repr__(self) -> str:
+                return "an instance of {}".format(self.__class__.__name__)
+
+        a = A(lst=[-1])
+
+        # NOTE (mristin):
+        # The invariant will be violated here through leaking, but ``a.lst`` is not
+        # changed, so no SETATTR event will be triggered.
+        a.lst.append(1)
+
+        violation_error = None  # type: Optional[icontract.ViolationError]
+        try:
+            # NOTE (mristin):
+            # The violation must occur here as we check it *before* and *after* every method.
+            a.do_something()
+        except icontract.ViolationError as err:
+            violation_error = err
+
+        self.assertIsNotNone(violation_error)
+        self.assertEqual(
+            textwrap.dedent(
+                """\
+                all(item < 0 for item in self.lst):
+                all(item < 0 for item in self.lst) was False, e.g., with
+                  item = 1
+                self was an instance of A
+                self.lst was [-1, 1]"""
+            ),
+            tests.error.wo_mandatory_location(str(violation_error)),
+        )
+
+    def test_invariant_checked_on_setattr_if_all_flag_set(self) -> None:
+        @icontract.invariant(
+            lambda self: self.x > 0, check_on=icontract.InvariantCheckEvent.ALL
+        )
+        class A:
+            def __init__(self) -> None:
+                self.x = 1
+
+            def __repr__(self) -> str:
+                return "an instance of {}".format(self.__class__.__name__)
+
+            def do_something_wrong(self) -> None:
+                self.x = -1
+
+        a = A()
+
+        violation_error = None  # type: Optional[icontract.ViolationError]
+        try:
+            a.do_something_wrong()
+        except icontract.ViolationError as err:
+            violation_error = err
+
+        self.assertIsNotNone(violation_error)
+        self.assertEqual(
+            textwrap.dedent(
+                """\
+                self.x > 0:
+                self was an instance of A
+                self.x was -1"""
+            ),
+            tests.error.wo_mandatory_location(str(violation_error)),
+        )
+
+    def test_invariant_checked_on_setattr_if_setattr_flag_set(self) -> None:
+        @icontract.invariant(
+            lambda self: self.x > 0, check_on=icontract.InvariantCheckEvent.ALL
+        )
+        class A:
+            def __init__(self) -> None:
+                self.x = 1
+
+            def __repr__(self) -> str:
+                return "an instance of {}".format(self.__class__.__name__)
+
+        a = A()
+
+        violation_error = None  # type: Optional[icontract.ViolationError]
+        try:
+            a.x = -1
+        except icontract.ViolationError as err:
+            violation_error = err
+
+        self.assertIsNotNone(violation_error)
+        self.assertEqual(
+            textwrap.dedent(
+                """\
+                self.x > 0:
+                self was an instance of A
+                self.x was -1"""
+            ),
+            tests.error.wo_mandatory_location(str(violation_error)),
+        )
+
+
 if __name__ == "__main__":
     unittest.main()
diff --git a/tests_3_7/test_invariant.py b/tests_3_7/test_invariant.py
index 60a9443..1a97d0b 100644
--- a/tests_3_7/test_invariant.py
+++ b/tests_3_7/test_invariant.py
@@ -108,5 +108,37 @@ class TestViolation(unittest.TestCase):
         )
 
 
+class TestCheckOn(unittest.TestCase):
+    def test_setattr_with_dataclass(self) -> None:
+        @icontract.invariant(
+            lambda self: self.x > 0, check_on=icontract.InvariantCheckEvent.SETATTR
+        )
+        @dataclasses.dataclass
+        class A:
+            x: int = 10
+
+            def __repr__(self) -> str:
+                return "an instance of {}".format(self.__class__.__name__)
+
+        a = A()
+
+        violation_error = None  # type: Optional[icontract.ViolationError]
+        try:
+            a.x = -1
+        except icontract.ViolationError as err:
+            violation_error = err
+
+        self.assertIsNotNone(violation_error)
+        self.assertEqual(
+            textwrap.dedent(
+                """\
+                self.x > 0:
+                self was an instance of A
+                self.x was -1"""
+            ),
+            tests.error.wo_mandatory_location(str(violation_error)),
+        )
+
+
 if __name__ == "__main__":
     unittest.main()