Coverage for Users / vladimirpavlov / PycharmProjects / parameterizable / tests / test_guarded_init_metaclass.py: 99%

195 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-01 16:37 -0600

1import pytest 

2import pickle 

3from dataclasses import dataclass 

4from mixinforge import GuardedInitMeta 

5 

6# --- Helper classes for pickling tests (must be at module level) --- 

7 

8class PickleClass(metaclass=GuardedInitMeta): 

9 def __init__(self, value): 

10 self._init_finished = False 

11 self.value = value 

12 

13 def __getstate__(self): 

14 state = self.__dict__.copy() 

15 if "_init_finished" in state: 

16 del state["_init_finished"] 

17 return state 

18 

19class BadPickleClass(metaclass=GuardedInitMeta): 

20 def __init__(self): 

21 self._init_finished = False 

22 

23class PostSetStateClass(metaclass=GuardedInitMeta): 

24 def __init__(self): 

25 self._init_finished = False 

26 self.restored = False 

27 

28 def __getstate__(self): 

29 state = self.__dict__.copy() 

30 state.pop('_init_finished', None) 

31 return state 

32 

33 def __post_setstate__(self): 

34 self.restored = True 

35 

36class ErrorPostSetStateClass(metaclass=GuardedInitMeta): 

37 def __init__(self): 

38 self._init_finished = False 

39 

40 def __getstate__(self): 

41 state = self.__dict__.copy() 

42 state.pop('_init_finished', None) 

43 return state 

44 

45 def __post_setstate__(self): 

46 raise ValueError("Restoration failed") 

47 

48# --- New Helper Classes --- 

49 

50class ParentWithSetState(metaclass=GuardedInitMeta): 

51 def __init__(self): 

52 self._init_finished = False 

53 def __getstate__(self): 

54 d = self.__dict__.copy() 

55 d.pop('_init_finished', None) 

56 return d 

57 def __setstate__(self, state): 

58 self.__dict__.update(state) 

59 self.setstate_called = True 

60 

61class ChildInheritsSetState(ParentWithSetState): 

62 pass 

63 

64class ClassDictOnly(metaclass=GuardedInitMeta): 

65 def __init__(self, value): 

66 self._init_finished = False 

67 self.value = value 

68 def __getstate__(self): 

69 d = self.__dict__.copy() 

70 d.pop('_init_finished', None) 

71 return d 

72 

73class ClassSlotsOnly(metaclass=GuardedInitMeta): 

74 __slots__ = ('value', '_init_finished') 

75 def __init__(self, value): 

76 self._init_finished = False 

77 self.value = value 

78 def __getstate__(self): 

79 return (None, {'value': self.value}) 

80 

81class ClassDictAndSlots(metaclass=GuardedInitMeta): 

82 __slots__ = ('s_val', '_init_finished', '__dict__') 

83 def __init__(self, d_val, s_val): 

84 self._init_finished = False 

85 self.d_val = d_val 

86 self.s_val = s_val 

87 def __getstate__(self): 

88 d = self.__dict__.copy() 

89 d.pop('_init_finished', None) 

90 return (d, {'s_val': self.s_val}) 

91 

92class FactoryClass(metaclass=GuardedInitMeta): 

93 def __new__(cls): 

94 return {"not": "instance"} 

95 def __init__(self): 

96 self._init_finished = False 

97 

98class BadPostInitClass(metaclass=GuardedInitMeta): 

99 __post_init__ = 123 

100 def __init__(self): 

101 self._init_finished = False 

102 

103class BadPostSetStateClass(metaclass=GuardedInitMeta): 

104 __post_setstate__ = "foo" 

105 def __init__(self): 

106 self._init_finished = False 

107 def __getstate__(self): 

108 d = self.__dict__.copy() 

109 d.pop('_init_finished', None) 

110 return d 

111 

112class SlotsMismatchClass(metaclass=GuardedInitMeta): 

113 __slots__ = ('x', '_init_finished') 

114 def __init__(self): 

115 self._init_finished = False 

116 def __getstate__(self): 

117 # Return a dict to trigger the mismatch error during setstate 

118 return {'x': 1} 

119 

120# --- Tests --- 

121 

122def test_basic_initialization(): 

123 """Test that initialization works correctly when contract is followed.""" 

124 class GoodClass(metaclass=GuardedInitMeta): 

125 def __init__(self): 

126 self._init_finished = False 

127 self.value = 10 

128 

129 obj = GoodClass() 

130 assert obj._init_finished is True 

131 assert obj.value == 10 

132 

133def test_missing_init_flag(): 

134 """Test that RuntimeError is raised if _init_finished is not set to False in __init__.""" 

135 class BadClass(metaclass=GuardedInitMeta): 

136 def __init__(self): 

137 self.value = 10 

138 # Missing self._init_finished = False 

139 

140 with pytest.raises(RuntimeError, match="must set attribute _init_finished to False"): 

141 BadClass() 

142 

143def test_post_init_hook(): 

144 """Test that __post_init__ is called.""" 

145 class PostInitClass(metaclass=GuardedInitMeta): 

146 def __init__(self): 

147 self._init_finished = False 

148 self.initialized_count = 0 

149 

150 def __post_init__(self): 

151 self.initialized_count += 1 

152 

153 obj = PostInitClass() 

154 assert obj._init_finished is True 

155 assert obj.initialized_count == 1 

156 

157def test_post_init_error(): 

158 """Test that errors in __post_init__ are re-raised with context.""" 

159 class ErrorPostInitClass(metaclass=GuardedInitMeta): 

160 def __init__(self): 

161 self._init_finished = False 

162 

163 def __post_init__(self): 

164 raise ValueError("Something went wrong") 

165 

166 with pytest.raises(ValueError, match="Error in __post_init__"): 

167 ErrorPostInitClass() 

168 

169def test_dataclass_rejection(): 

170 """Test that applying GuardedInitMeta to a dataclass raises TypeError on instantiation.""" 

171 # Note: definition succeeds because is_dataclass is false during __init__ 

172 @dataclass 

173 class MyDataclass(metaclass=GuardedInitMeta): 

174 x: int 

175 

176 with pytest.raises(TypeError, match="GuardedInitMeta cannot be used with dataclass"): 

177 MyDataclass(10) 

178 

179def test_pickle_success(): 

180 """Test successful pickle/unpickle cycle with proper __getstate__.""" 

181 obj = PickleClass(42) 

182 assert obj._init_finished is True 

183 

184 data = pickle.dumps(obj) 

185 new_obj = pickle.loads(data) 

186 

187 assert new_obj.value == 42 

188 assert new_obj._init_finished is True 

189 assert isinstance(new_obj, PickleClass) 

190 

191def test_pickle_failure_if_init_finished_present(): 

192 """Test that unpickling fails if _init_finished=True is present in state.""" 

193 obj = BadPickleClass() 

194 # Default pickling includes _init_finished=True 

195 data = pickle.dumps(obj) 

196 

197 with pytest.raises(RuntimeError, match="must not be pickled with _init_finished=True"): 

198 pickle.loads(data) 

199 

200def test_post_setstate_hook(): 

201 """Test that __post_setstate__ is called after unpickling.""" 

202 obj = PostSetStateClass() 

203 data = pickle.dumps(obj) 

204 new_obj = pickle.loads(data) 

205 

206 assert new_obj.restored is True 

207 assert new_obj._init_finished is True 

208 

209def test_post_setstate_error(): 

210 """Test that errors in __post_setstate__ are re-raised with context.""" 

211 obj = ErrorPostSetStateClass() 

212 data = pickle.dumps(obj) 

213 

214 with pytest.raises(ValueError, match="Error in __post_setstate__"): 

215 pickle.loads(data) 

216 

217# --- New Tests --- 

218 

219def test_inherited_setstate_wrapped_once(): 

220 """Verify inherited __setstate__ is wrapped only once and behaves correctly.""" 

221 obj = ChildInheritsSetState() 

222 data = pickle.dumps(obj) 

223 restored = pickle.loads(data) 

224 

225 assert restored._init_finished is True 

226 assert getattr(restored, 'setstate_called', False) is True 

227 # Verify object identity of the method 

228 assert ChildInheritsSetState.__setstate__ is ParentWithSetState.__setstate__ 

229 

230@pytest.mark.parametrize("cls, init_args, check_fn", [ 

231 (ClassDictOnly, (10,), lambda o: o.value == 10), 

232 (ClassSlotsOnly, (20,), lambda o: o.value == 20), 

233 (ClassDictAndSlots, (30, 40), lambda o: o.d_val == 30 and o.s_val == 40), 

234]) 

235def test_default_restore_paths(cls, init_args, check_fn): 

236 """Cover default restore paths when no __setstate__ is present.""" 

237 obj = cls(*init_args) 

238 assert obj._init_finished is True 

239 

240 data = pickle.dumps(obj) 

241 restored = pickle.loads(data) 

242 

243 assert restored._init_finished is True 

244 assert check_fn(restored) 

245 

246def test_new_returns_non_instance(): 

247 """Ensure lifecycle hooks are skipped when __new__ returns a non-instance.""" 

248 obj = FactoryClass() 

249 assert isinstance(obj, dict) 

250 assert not hasattr(obj, "_init_finished") 

251 

252def test_reject_non_callable_hooks(): 

253 """Reject non-callable hooks early.""" 

254 with pytest.raises(TypeError, match="__post_init__ must be callable"): 

255 BadPostInitClass() 

256 

257 obj = BadPostSetStateClass() 

258 data = pickle.dumps(obj) 

259 with pytest.raises(TypeError, match="__post_setstate__ must be callable"): 

260 pickle.loads(data) 

261 

262def test_slots_mismatch_guard(): 

263 """Slots mismatch guard raises RuntimeError.""" 

264 obj = SlotsMismatchClass() 

265 data = pickle.dumps(obj) 

266 with pytest.raises(RuntimeError, match="instance has no __dict__"): 

267 pickle.loads(data) 

268 

269def test_dataclass_definition_rejection(): 

270 """Dataclass rejection at class-definition time.""" 

271 @dataclass 

272 class BaseDataclass: 

273 x: int 

274 

275 with pytest.raises(TypeError, match="GuardedInitMeta cannot be used with dataclass"): 

276 class Child(BaseDataclass, metaclass=GuardedInitMeta): 

277 pass