Skip to content

Commit df646dc

Browse files
committed
Fix child invariants checked in super().__init__
Previously, we determined the invariants based on the `self` passed to the function. However, in case of `super().__init__`, the invariants that need to be checked after the call are those belonging to to the super class, not the current (child) class. This change fixes the issue by passing in the class corresponding to the invariants alongside function and parameters, so that we can properly resolve which invariants need to be checked. Fixes #300.
1 parent ecccd02 commit df646dc

File tree

2 files changed

+491
-19
lines changed

2 files changed

+491
-19
lines changed

icontract/_checkers.py

Lines changed: 97 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -979,13 +979,14 @@ def wrapper(*args, **kwargs): # type: ignore
979979
return wrapper # type: ignore
980980

981981

982-
def _decorate_with_invariants(func: CallableT, is_init: bool) -> CallableT:
982+
def _decorate_with_invariants(func: CallableT, cls: ClassT, is_init: bool) -> CallableT:
983983
"""
984984
Decorate the method ``func`` with invariant checks.
985985
986986
If the function has been already decorated with invariant checks, the function returns immediately.
987987
988988
:param func: function to be wrapped
989+
:param cls: class corresponding to the invariant and ``func``
989990
:param is_init: True if the ``func`` is __init__
990991
:return: function wrapped with invariant checks
991992
"""
@@ -1027,7 +1028,25 @@ def wrapper(*args, **kwargs): # type: ignore
10271028
try:
10281029
result = func(*args, **kwargs)
10291030

1030-
for invariant in instance.__class__.__invariants__:
1031+
# NOTE (mristin):
1032+
# We go to the invariants corresponding to the class, not the instance, as we need to
1033+
# account also for a situation where super().__init__ is called. Here is an example:
1034+
#
1035+
# @invariant(lambda self: ...)
1036+
# class A(DBC):
1037+
# pass
1038+
#
1039+
# @invariant(lambda self: ...)
1040+
# class B(A):
1041+
# def __init__(self) -> None:
1042+
# super().__init__()
1043+
# # ↖ After this call, only the invariants of A, but not B, have to be checked.
1044+
# #
1045+
# # However, the ``instance`` (i.e., resolved ``self``) in super().__init__ call points to
1046+
# # an instance of B, so instance.__cls__.__invariants__ refer to invariants of B, not A.
1047+
1048+
# noinspection PyUnresolvedReferences
1049+
for invariant in cls.__invariants__: # type: ignore
10311050
_assert_invariant(contract=invariant, instance=instance)
10321051

10331052
return result
@@ -1273,35 +1292,97 @@ def add_invariant_checks(cls: ClassT) -> None:
12731292
)
12741293
)
12751294

1276-
if init_func:
1277-
# We have to distinguish this special case which is used by named
1278-
# tuples and possibly other optimized data structures.
1279-
# In those cases, we have to wrap __new__ instead of __init__.
1280-
if init_func == object.__init__ and hasattr(cls, "__new__"):
1281-
new_func = getattr(cls, "__new__")
1282-
setattr(cls, "__new__", _decorate_new_with_invariants(new_func))
1283-
else:
1284-
wrapper = _decorate_with_invariants(func=init_func, is_init=True)
1285-
setattr(cls, init_func.__name__, wrapper)
1295+
assert init_func is not None, "Every class in Python must have a constructor."
1296+
1297+
# We must handle this special case which is used by named
1298+
# tuples and possibly other optimized data structures.
1299+
# In those cases, we have to wrap __new__ instead of __init__.
1300+
if init_func == object.__init__ and hasattr(cls, "__new__"):
1301+
new_func = getattr(cls, "__new__")
1302+
setattr(cls, "__new__", _decorate_new_with_invariants(new_func))
1303+
else:
1304+
# NOTE (mristin):
1305+
# We have to create a new __init__ function so that the invariants of *this* class are checked.
1306+
# The problem arises due to two different cases related to inheritance which we can not distinguish in Python.
1307+
# Namely, we can not know whether we are dealing with invariants coming from ``super().__init__`` or
1308+
# an implicit call to ``__init__``.
1309+
#
1310+
# In both of these edge cases, the instance is of the child class, but the constructors are
1311+
# of the parent class. Checking the invariants attached to the class would break the second case, while checking
1312+
# the invariants attached to the instance (through ``self.__class__.__invariants__``) would break the first
1313+
# case.
1314+
#
1315+
# The following snippets depict the two cases.
1316+
#
1317+
# Case 1: ``super().__init__``
1318+
# @invariant(lambda self: ...)
1319+
# class A(DBC):
1320+
# pass
1321+
#
1322+
# @invariant(lambda self: ...)
1323+
# class B(A):
1324+
# def __init__(self) -> None:
1325+
# super().__init__()
1326+
# # ↖ After this call, only the invariants of A, but not B, have to be checked.
1327+
# # More code follows, and after this ``__init__``, invariants of B have to be checked.
1328+
#
1329+
# Case 2: Implicit ``__init__`` call
1330+
# @invariant(lambda self: ...)
1331+
# class A(DBC):
1332+
# pass
1333+
#
1334+
# @invariant(lambda self: ...)
1335+
# class B(A):
1336+
# pass
1337+
#
1338+
# b = B()
1339+
# # ↖ After this call, the invariants of B have to be checked.
1340+
# # However, we only see the call to A.__init__, since there is no B.__init__.
1341+
#
1342+
# Therefore, to avert this problem, we have to create an ``__init__`` in the child class for the second
1343+
# case. This allows us to always check for invariants attached to the class in the case of constructors, so both
1344+
# cases can be successfully handled.
1345+
1346+
if "__init__" not in cls.__dict__:
1347+
init_after_mro = (
1348+
# NOTE (mristin):
1349+
# mypy gives us the following warning:
1350+
# Accessing "__init__" on an instance is unsound, since instance.__init__ could be from an incompatible
1351+
# subclass
1352+
#
1353+
# ... but this is exactly what we want here -- we want to look up the __init__ of the class at runtime.
1354+
cls.__init__ # type: ignore
1355+
) # This is the constructor after MRO, pointing to one of the parent classes.
1356+
1357+
def __init__(self: Any, *args: Any, **kwargs: Any) -> None:
1358+
init_after_mro(self, *args, **kwargs)
1359+
1360+
# NOTE (mristin):
1361+
# See the comment above corresponding to this mypy warning.
1362+
cls.__init__ = __init__ # type: ignore
1363+
init_func = __init__
1364+
1365+
wrapper = _decorate_with_invariants(func=init_func, cls=cls, is_init=True)
1366+
setattr(cls, init_func.__name__, wrapper)
12861367

12871368
for name, func in names_funcs:
1288-
wrapper = _decorate_with_invariants(func=func, is_init=False)
1369+
wrapper = _decorate_with_invariants(func=func, cls=cls, is_init=False)
12891370
setattr(cls, name, wrapper)
12901371

12911372
for name, prop in names_properties:
12921373
new_prop = property(
12931374
fget=(
1294-
_decorate_with_invariants(func=prop.fget, is_init=False)
1375+
_decorate_with_invariants(func=prop.fget, cls=cls, is_init=False)
12951376
if prop.fget
12961377
else None
12971378
),
12981379
fset=(
1299-
_decorate_with_invariants(func=prop.fset, is_init=False)
1380+
_decorate_with_invariants(func=prop.fset, cls=cls, is_init=False)
13001381
if prop.fset
13011382
else None
13021383
),
13031384
fdel=(
1304-
_decorate_with_invariants(func=prop.fdel, is_init=False)
1385+
_decorate_with_invariants(func=prop.fdel, cls=cls, is_init=False)
13051386
if prop.fdel
13061387
else None
13071388
),

0 commit comments

Comments
 (0)