Coverage for src/objectstate/dual_axis_resolver.py: 94%

98 statements  

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

1""" 

2Generic dual-axis resolver for lazy configuration inheritance. 

3 

4``resolve_with_provenance`` is the one resolution of a field in the active context, 

5over both the context hierarchy (X-axis) and sibling inheritance (Y-axis). It returns 

6the value together with the scope and type that supplied it. 

7 

8The resolver is completely generic and has no application-specific dependencies. 

9""" 

10 

11import functools 

12import logging 

13from dataclasses import MISSING, fields, is_dataclass 

14from typing import Any, NamedTuple 

15 

16logger = logging.getLogger(__name__) 

17 

18 

19class ResolvedField(NamedTuple): 

20 """What a field resolves to in one context, with the layer that supplied it. 

21 

22 ``source_scope`` is the scope id of the supplying layer and ``source_type`` the 

23 config type it was read from, which is the container type or one of its bases. 

24 

25 When no layer holds a value, ``value`` is the declared default (or ``None``) and 

26 the source names where the field lives in the context: the outermost layer holding 

27 the nearest type in the MRO. Both are ``None`` when no layer holds such a type. 

28 """ 

29 

30 value: Any 

31 source_scope: str | None 

32 source_type: type | None 

33 

34 

35@functools.lru_cache(maxsize=None) 

36def _normalize_to_base(t: type) -> type: 

37 """Normalize lazy type to its base type for comparison. 

38 

39 LazyPartitionFilterConfig -> PartitionFilterConfig 

40 PartitionFilterConfig -> PartitionFilterConfig 

41 

42 PERFORMANCE: Results are cached with lru_cache since type->base_type 

43 mappings are immutable after class creation. Import is hoisted to 

44 avoid per-call import overhead. 

45 """ 

46 from objectstate.lazy_factory import get_base_type_for_lazy 

47 return get_base_type_for_lazy(t) or t 

48 

49 

50def _direct_owner_field_default(config_type: type, field_name: str) -> tuple[bool, Any]: 

51 """Return the default owned by config_type for field_name. 

52 

53 Lazy config resolution intentionally forces inherited dataclass fields to 

54 None so they can inherit through context and MRO. Fields declared directly 

55 on the concrete owner still have their semantic class defaults on the base 

56 config type. Those defaults are load-bearing when no scope supplies a 

57 concrete value. 

58 """ 

59 if not is_dataclass(config_type): 

60 return False, None 

61 

62 field_obj = config_type.__dataclass_fields__.get(field_name) 

63 if field_obj is None: 

64 return False, None 

65 

66 metadata = field_obj.metadata or {} 

67 if "_inherited_default" in metadata or "_inherited_default_factory" in metadata: 

68 return False, None 

69 

70 if field_obj.default is not MISSING: 

71 return True, field_obj.default 

72 

73 if field_obj.default_factory is not MISSING: 

74 return True, field_obj.default_factory() 

75 

76 return False, None 

77 

78 

79def declared_default(container_type: type, field_name: str) -> tuple[Any, type | None]: 

80 """The value a field has when no context layer supplies one. 

81 

82 In order: the default the base type declares for the field itself, the first 

83 concrete class attribute along the base type's MRO, then the default the field 

84 had before it was rebuilt to inherit (kept in the field's metadata). 

85 

86 Returns the value and, when the base type declares a default for the field 

87 itself, that base type; otherwise ``None`` in its place. 

88 """ 

89 container_base = _normalize_to_base(container_type) 

90 has_own_default, own_default = _direct_owner_field_default(container_base, field_name) 

91 own_default_type = container_base if has_own_default else None 

92 if own_default is not None: 

93 return own_default, own_default_type 

94 

95 for mro_class in container_base.__mro__: 

96 class_value = getattr(mro_class, field_name, None) 

97 if class_value is not None: 

98 return class_value, own_default_type 

99 

100 if is_dataclass(container_type): 

101 for field_definition in fields(container_type): 

102 if field_definition.name != field_name: 

103 continue 

104 metadata = field_definition.metadata 

105 inherited = metadata.get("_inherited_default", MISSING) 

106 if inherited is not MISSING: 

107 return inherited, own_default_type 

108 factory = metadata.get("_inherited_default_factory", MISSING) 

109 if factory is not MISSING: 

110 return factory(), own_default_type 

111 return None, own_default_type 

112 

113 

114def resolve_with_provenance(container_type: type, field_name: str) -> ResolvedField: 

115 """ 

116 Resolve a field in the active context: its value and the layer that supplied it. 

117 

118 This is the one resolution. Attribute access on a lazy config takes ``.value`` 

119 from it; ``ObjectState`` takes the value and the source together. 

120 

121 The answer is kept on the active layer stack, so it lives exactly as long as the 

122 context it was computed from. 

123 

124 Args: 

125 container_type: The type containing the field (e.g., LazyPathPlanningConfig) 

126 field_name: Name of the field to resolve (e.g., "partition_filter") 

127 

128 Raises: 

129 NoActiveContextError: no ``config_context`` is active. 

130 """ 

131 from objectstate.context_manager import NoActiveContextError, context_layer_stack 

132 

133 stack = context_layer_stack.get() 

134 if not stack.layers: 

135 raise NoActiveContextError( 

136 f"Cannot resolve {container_type.__name__}.{field_name}: " 

137 "no config context is active." 

138 ) 

139 return resolve_in_stack(stack, container_type, field_name) 

140 

141 

142def resolve_in_stack(stack, container_type: type, field_name: str) -> ResolvedField: 

143 """``resolve_with_provenance`` for a layer stack the caller already holds.""" 

144 key = (container_type, field_name) 

145 resolved = stack.resolved_fields.get(key) 

146 if resolved is None: 

147 resolved = stack.resolved_fields[key] = _resolve_in_layers( 

148 stack.layers, container_type, field_name 

149 ) 

150 return resolved 

151 

152 

153def _resolve_in_layers(layers: tuple, container_type: type, field_name: str) -> ResolvedField: 

154 """Resolve class first: the MRO is the outer loop, the layers the inner one. 

155 

156 For each class in the container type's MRO, most specific first, every layer is 

157 asked from the innermost scope to the outermost; the first value that is not 

158 ``None`` wins. ``None`` in an inner layer means "inherit", so a less specific 

159 class is tried only after every layer was asked for the more specific one. 

160 A layer that masks the layers outside it ends the walk outward. 

161 

162 The order is stated with an example in ``docs/context_system.rst`` under 

163 "Precedence Order"; this function is its implementation. 

164 """ 

165 container_base = _normalize_to_base(container_type) 

166 

167 visible_layers = [] 

168 for layer in reversed(layers): 

169 visible_layers.append(layer) 

170 if layer.masks_outer_layers: 

171 break 

172 

173 # Where the field lives when every layer leaves it None: the outermost layer 

174 # holding the first MRO type that any layer holds. 

175 unset_scope: str | None = None 

176 unset_type: type | None = None 

177 unset_found = False 

178 

179 for mro_class in container_base.__mro__: 

180 if not is_dataclass(mro_class): 

181 continue 

182 mro_type = _normalize_to_base(mro_class) 

183 outermost_scope: str | None = None 

184 held_by_a_layer = False 

185 

186 for layer in visible_layers: 

187 config_instance = layer.configs.get(mro_type) 

188 if config_instance is None: 

189 continue 

190 held_by_a_layer = True 

191 outermost_scope = layer.scope_id 

192 try: 

193 value = object.__getattribute__(config_instance, field_name) 

194 except AttributeError: 

195 continue 

196 if value is not None: 

197 return ResolvedField(value, layer.scope_id, mro_type) 

198 

199 if held_by_a_layer and not unset_found: 

200 unset_found = True 

201 unset_scope = outermost_scope 

202 unset_type = mro_type 

203 

204 default_value, own_default_type = declared_default(container_type, field_name) 

205 return ResolvedField(default_value, unset_scope, own_default_type or unset_type) 

206 

207 

208def get_field_provenance( 

209 container_type: type, field_name: str 

210) -> tuple[str | None, type | None]: 

211 """ 

212 Find which scope AND type provided the concrete value for a field. 

213 

214 The scope and type of ``resolve_with_provenance()``, without the value. 

215 

216 Args: 

217 container_type: The type containing the field (e.g., LazyPathPlanningConfig) 

218 field_name: Name of the field to find provenance for (e.g., "partition_filter") 

219 

220 Returns: 

221 (source_scope_id, source_type): The scope_id and type that provided the value. 

222 source_type may differ from container_type due to MRO inheritance. 

223 See ``ResolvedField`` for what they name when no layer has a concrete value. 

224 """ 

225 _, source_scope, source_type = resolve_with_provenance(container_type, field_name) 

226 return source_scope, source_type