Coverage for src/objectstate/live_context_resolver.py: 91%

54 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-17 07:22 +0000

1""" 

2Live context resolution service for configuration framework. 

3 

4Provides cached resolution of config attributes using live values, 

5avoiding redundant context building and resolution operations. 

6 

7This service is completely generic and UI-agnostic: 

8- No knowledge of ParameterFormManager or any UI components 

9- No knowledge of steps, orchestrators, or domain concepts 

10- Only knows about dataclasses, context stacks, and attribute resolution 

11- Caller is responsible for providing live context data 

12""" 

13 

14import logging 

15from dataclasses import is_dataclass 

16from typing import Any 

17 

18from objectstate.context_manager import config_context 

19from objectstate.lazy_factory import replace_raw 

20 

21logger = logging.getLogger(__name__) 

22 

23 

24class _ResolvedAttr: 

25 """One answer, held together with the objects it was computed from. 

26 

27 Holding the objects keeps them alive for as long as the answer is kept, and 

28 the answer is found again by comparing those objects with ``is``. 

29 """ 

30 

31 __slots__ = ("config_obj", "context_stack", "value") 

32 

33 def __init__(self, config_obj: object, context_stack: tuple[object, ...], value: Any): 

34 self.config_obj = config_obj 

35 self.context_stack = context_stack 

36 self.value = value 

37 

38 def computed_from(self, config_obj: object, context_stack: list) -> bool: 

39 return ( 

40 self.config_obj is config_obj 

41 and len(self.context_stack) == len(context_stack) 

42 and all(kept is given for kept, given in zip(self.context_stack, context_stack)) 

43 ) 

44 

45 

46class LiveContextResolver: 

47 """ 

48 Pure service for resolving config attributes with live values. 

49 

50 Keeps resolved values to avoid expensive context stack building + resolution. 

51 The answers kept are those of one cache token. Each is stored with the config 

52 object and the context objects it was computed from, so it is returned only 

53 for those same objects. Answers of an earlier token are dropped when a new 

54 token arrives. 

55 

56 Completely generic - works with any dataclasses and any context hierarchy. 

57 UI layer is responsible for: 

58 - Collecting live context from form managers 

59 - Providing the current token 

60 - Building the context stack 

61 """ 

62 

63 def __init__(self): 

64 self._answers_token: int | None = None 

65 self._answers_by_attr: dict[str, list[_ResolvedAttr]] = {} 

66 

67 def resolve_config_attr( 

68 self, 

69 config_obj: object, 

70 attr_name: str, 

71 context_stack: list, 

72 live_context: dict[type, dict[str, Any]], 

73 cache_token: int, 

74 ) -> Any: 

75 """ 

76 Resolve config attribute through context hierarchy with caching. 

77 

78 Completely generic - no knowledge of UI, steps, orchestrators, or domain concepts. 

79 

80 Args: 

81 config_obj: Config dataclass instance to resolve attribute from 

82 attr_name: Attribute name to resolve (e.g., 'enabled') 

83 context_stack: List of context objects to resolve through (e.g., [global_config, pipeline_config, step]) 

84 live_context: Live values from form managers, keyed by type 

85 cache_token: Current cache token for invalidation 

86 

87 Returns: 

88 Resolved attribute value 

89 """ 

90 if cache_token != self._answers_token: 

91 self._answers_by_attr = {} 

92 self._answers_token = cache_token 

93 

94 answers = self._answers_by_attr.setdefault(attr_name, []) 

95 for answer in answers: 

96 if answer.computed_from(config_obj, context_stack): 

97 return answer.value 

98 

99 resolved_value = self._resolve_uncached( 

100 config_obj, attr_name, context_stack, live_context 

101 ) 

102 answers.append(_ResolvedAttr(config_obj, tuple(context_stack), resolved_value)) 

103 return resolved_value 

104 

105 def invalidate(self) -> None: 

106 """Drop every kept answer.""" 

107 self._answers_token = None 

108 self._answers_by_attr = {} 

109 

110 def reconstruct_live_values(self, live_values: dict[str, Any]) -> dict[str, Any]: 

111 """Return live values unchanged.""" 

112 return live_values if live_values else {} 

113 

114 def _resolve_uncached( 

115 self, 

116 config_obj: object, 

117 attr_name: str, 

118 context_stack: list, 

119 live_context: dict[type, dict[str, Any]] 

120 ) -> Any: 

121 """Resolve config attribute through context hierarchy (uncached).""" 

122 # Merge live values into each context object 

123 merged_contexts = [ 

124 self._merge_live_values(ctx, live_context.get(type(ctx))) 

125 for ctx in context_stack 

126 ] 

127 

128 # Resolve through nested context stack 

129 return self._resolve_through_contexts(merged_contexts, config_obj, attr_name) 

130 

131 def _resolve_through_contexts(self, merged_contexts: list, config_obj: object, attr_name: str) -> Any: 

132 """Resolve through nested context stack using config_context().""" 

133 # Build nested context managers 

134 if not merged_contexts: 

135 # No context - just get attribute directly 

136 return getattr(config_obj, attr_name) 

137 

138 # Nest contexts from outermost to innermost 

139 def resolve_in_context(contexts_remaining): 

140 if not contexts_remaining: 

141 # Innermost level - get the attribute 

142 return getattr(config_obj, attr_name) 

143 

144 # Enter context and recurse 

145 ctx = contexts_remaining[0] 

146 with config_context(ctx): 

147 return resolve_in_context(contexts_remaining[1:]) 

148 

149 return resolve_in_context(merged_contexts) 

150 

151 def _merge_live_values(self, base_obj: object, live_values: dict[str, Any] | None) -> object: 

152 """Merge live values into base object. 

153 

154 CRITICAL: Passes None values through to dataclasses.replace(). When a field is reset 

155 to None in a form, the None value should override the saved concrete value in the 

156 base object. This allows the lazy resolution system to walk up the MRO to find the 

157 inherited value from parent context. 

158 

159 Example: PipelineConfig form resets partition_filter_config.enabled to None 

160 → dataclasses.replace(saved_pipeline_config, 

161 partition_filter_config=LazyPartitionFilterConfig(enabled=None)) 

162 → When resolving enabled, the None triggers MRO walk to GlobalPipelineConfig 

163 """ 

164 if live_values is None or not is_dataclass(base_obj): 

165 return base_obj 

166 

167 # Reconstruct nested dataclasses recursively 

168 reconstructed_values = self.reconstruct_live_values(live_values) 

169 

170 # Merge into base object (including None values to override saved concrete values) 

171 # Use replace_raw to preserve None values (dataclasses.replace triggers lazy resolution) 

172 if reconstructed_values: 

173 return replace_raw(base_obj, **reconstructed_values) 

174 else: 

175 return base_obj