Coverage for Users / vladimirpavlov / PycharmProjects / parameterizable / src / mixinforge / guarded_init_metaclass.py: 98%
91 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-01 16:37 -0600
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-01 16:37 -0600
1"""Metaclass for strict initialization control and lifecycle hooks.
3This module provides GuardedInitMeta, a metaclass that enforces a strict
4initialization contract where _init_finished is set to True only after
5complete initialization. It wraps __setstate__ to ensure proper state
6restoration during unpickling and provides hooks for post-initialization
7and post-unpickling tasks.
8"""
9import functools
10from abc import ABCMeta
11from dataclasses import is_dataclass
12from typing import Any, Type, TypeVar
14T = TypeVar('T')
17def _validate_pickle_state_integrity(state: Any, cls_name: str) -> None:
18 """Ensure pickled state does not claim initialization is finished.
20 Args:
21 state: The pickle state to validate.
22 cls_name: Class name for error reporting.
24 Raises:
25 RuntimeError: If _init_finished is True in the pickled state.
26 """
27 candidate_dict, _ = _parse_pickle_state(state, cls_name)
29 if candidate_dict is not None and candidate_dict.get("_init_finished") is True:
30 raise RuntimeError(
31 f"{cls_name} must not be pickled with _init_finished=True")
34def _parse_pickle_state(state: Any, cls_name: str) -> tuple[dict | None, dict | None]:
35 """Extract __dict__ and __slots__ state from pickle data.
37 Args:
38 state: The state object passed to __setstate__.
39 cls_name: Class name for error reporting.
41 Returns:
42 A tuple (dict_state, slots_state) where each element is a dictionary or None.
44 Raises:
45 RuntimeError: If state format is unsupported.
46 """
47 if state is None:
48 return None, None
49 elif isinstance(state, dict):
50 return state, None
51 elif (isinstance(state, tuple) and len(state) == 2
52 and (state[0] is None or isinstance(state[0], dict))
53 and (state[1] is None or isinstance(state[1], dict))):
54 return state
55 else:
56 raise RuntimeError(
57 f"Unsupported pickle state for {cls_name}: {state!r}")
60def _restore_dict_state(instance: Any, state_dict: dict, cls_name: str) -> None:
61 """Update instance __dict__ with restored state.
63 Args:
64 instance: The object instance being restored.
65 state_dict: Dictionary of attribute values to restore.
66 cls_name: Class name for error reporting.
68 Raises:
69 RuntimeError: If instance has no __dict__ attribute.
70 """
71 if hasattr(instance, "__dict__"):
72 instance.__dict__.update(state_dict)
73 else:
74 raise RuntimeError(
75 f"Cannot restore pickle state for {cls_name}: "
76 f"instance has no __dict__ but state contains a dictionary.")
79def _restore_slots_state(instance: Any, state_slots: dict[str,Any]) -> None:
80 """Restore slot values using setattr.
82 Args:
83 instance: The object instance being restored.
84 state_slots: Dictionary mapping slot names to values.
85 """
86 for key, value in state_slots.items():
87 setattr(instance, key, value)
90def _invoke_post_setstate_hook(instance: Any) -> None:
91 """Execute __post_setstate__ hook if defined.
93 Args:
94 instance: The object instance to invoke the hook on.
96 Raises:
97 TypeError: If __post_setstate__ is not callable.
98 """
99 post_setstate = getattr(instance, "__post_setstate__", None)
100 if post_setstate:
101 if not callable(post_setstate):
102 raise TypeError(f"__post_setstate__ must be callable, "
103 f"got {instance.__post_setstate__!r}")
104 try:
105 post_setstate()
106 except Exception as e:
107 _re_raise_with_context("__post_setstate__", e)
110class GuardedInitMeta(ABCMeta):
111 """Metaclass for strict initialization control and lifecycle hooks.
113 Enforces a contract where _init_finished is False during initialization
114 and only becomes True after all initialization code completes. This ensures
115 that properties and methods can reliably check initialization state.
117 The metaclass automatically wraps __setstate__ to maintain the same
118 contract during unpickling, and invokes __post_init__ and __post_setstate__
119 hooks when defined.
121 Contract:
122 - __init__ must set self._init_finished = False immediately.
123 - The metaclass sets self._init_finished = True after __init__ returns
124 (but before __post_init__, if defined).
125 - __setstate__ is wrapped to ensure _init_finished becomes True after
126 full state restoration (but before __post_setstate__, if defined).
127 """
129 def __init__(cls, name, bases, dct):
130 """Initialize the class and inject lifecycle enforcement.
132 Wraps __setstate__ to ensure proper initialization state after unpickling
133 and validates that the class is compatible with the GuardedInitMeta contract.
135 Args:
136 name: The class name.
137 bases: Base classes.
138 dct: Class dictionary.
140 Raises:
141 TypeError: If class is a dataclass or has multiple GuardedInitMeta bases.
142 """
143 super().__init__(name, bases, dct)
144 _raise_if_dataclass(cls)
146 n_guarded_bases = sum(1 for base in bases if isinstance(base, GuardedInitMeta))
147 if n_guarded_bases > 1:
148 raise TypeError(f"Class {name} has {n_guarded_bases} GuardedInitMeta bases, "
149 "but only 1 is allowed.")
151 if '__setstate__' in dct:
152 original_setstate = dct['__setstate__']
153 elif getattr(cls, '__setstate__', None) is not None:
154 inherited = getattr(cls, '__setstate__')
155 if getattr(inherited, "__guarded_init_meta_wrapped__", False):
156 return
157 original_setstate = inherited
158 else:
159 original_setstate = None
161 def setstate_wrapper(self, state):
162 """Restore state, finalize initialization, and invoke hook."""
163 _validate_pickle_state_integrity(state, type(self).__name__)
165 if original_setstate is not None:
166 original_setstate(self, state)
167 else:
168 state_dict, state_slots = _parse_pickle_state(state, type(self).__name__)
170 if state_dict is not None:
171 _restore_dict_state(self, state_dict, type(self).__name__)
173 if state_slots is not None:
174 _restore_slots_state(self, state_slots)
176 if isinstance(self, cls):
177 self._init_finished = True
178 _invoke_post_setstate_hook(self)
180 if original_setstate:
181 setstate_wrapper = functools.wraps(original_setstate)(setstate_wrapper)
183 setstate_wrapper.__guarded_init_meta_wrapped__ = True
184 setstate_wrapper.__name__ = '__setstate__'
185 setattr(cls, '__setstate__', setstate_wrapper)
187 def __call__(cls: Type[T], *args: Any, **kwargs: Any) -> T:
188 """Create instance, enforce initialization contract, and invoke hook.
190 Ensures _init_finished is False during __init__, sets it to True afterward,
191 and invokes __post_init__ if defined.
193 Args:
194 *args: Positional arguments for __init__.
195 **kwargs: Keyword arguments for __init__.
197 Returns:
198 The initialized instance.
200 Raises:
201 RuntimeError: If _init_finished is not False after __init__.
202 TypeError: If __post_init__ is not callable.
203 """
204 _raise_if_dataclass(cls)
206 instance = super().__call__(*args, **kwargs)
207 if not isinstance(instance, cls):
208 return instance
210 if not hasattr(instance, '_init_finished') or instance._init_finished:
211 raise RuntimeError(f"Class {cls.__name__} must set attribute "
212 "_init_finished to False in __init__")
214 instance._init_finished = True
216 post_init = getattr(instance, "__post_init__", None)
217 if post_init:
218 if not callable(post_init):
219 raise TypeError(f"__post_init__ must be callable, "
220 f"got {instance.__post_init__!r}")
221 try:
222 post_init()
223 except Exception as e:
224 _re_raise_with_context("__post_init__", e)
226 return instance
229def _re_raise_with_context(hook_name: str, exc: Exception) -> None:
230 """Re-raise an exception with added context about the hook.
232 Args:
233 hook_name: The hook name where the error occurred (e.g., "__post_init__").
234 exc: The original exception caught during hook execution.
236 Raises:
237 RuntimeError: If the exception constructor is incompatible.
238 Exception: The augmented exception with added context.
239 """
240 try:
241 new_exc = type(exc)(f"Error in {hook_name}: {exc}")
242 except Exception:
243 raise RuntimeError(
244 f"Error in {hook_name} (original error: {type(exc).__name__}: {exc})"
245 ) from exc
247 raise new_exc from exc
250def _raise_if_dataclass(cls: Type) -> None:
251 """Forbid GuardedInitMeta on dataclasses due to incompatible lifecycle.
253 This check runs in two places:
254 1. In GuardedInitMeta.__init__ - catches inheritance from dataclasses.
255 2. In GuardedInitMeta.__call__ - catches @dataclass decorator on the class itself.
257 Args:
258 cls: The class to check.
260 Raises:
261 TypeError: If the class is a dataclass.
262 """
263 if is_dataclass(cls):
264 raise TypeError(
265 f"GuardedInitMeta cannot be used with dataclass class {cls.__name__} "
266 "because dataclasses already manage __post_init__ with different "
267 "object lifecycle assumptions.")