Coverage for src/objectstate/resolution_sources.py: 92%

86 statements  

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

1"""The objects a state's resolved values are computed from. 

2 

3A resolved value of a state is a function of three things: which states are 

4registered above it, what each of them holds for the field's config type and 

5field, and what the global config holds. ``ResolutionSources`` is that input as 

6one value: the global config and the ancestors' objects, in the order the context 

7layers are entered. A resolved snapshot keeps the sources it was computed from; 

8it is current exactly while the state would be given the same objects today. 

9 

10When the objects are not the same, ``fields_that_differ`` names the 

11``(config type, field name)`` pairs whose stored value is another one, read the 

12way field resolution reads a layer (``extract_all_configs``). Only fields that 

13can inherit one of those pairs need to be resolved again. 

14 

15Objects are compared by identity. ``ObjectState.to_object`` builds a new object 

16whenever a parameter changed, and ``set_global_config`` stores another 

17instance, so another identity is the signal that values may differ. Every state 

18below an edited ancestor asks about the same pair of objects, so the answer for 

19a pair is kept and computed once per edit. 

20""" 

21 

22from collections import OrderedDict 

23from dataclasses import fields, is_dataclass 

24from typing import Any, NamedTuple 

25 

26FieldKey = tuple[type, str] 

27 

28# How many (old object, new object) answers are kept. Each entry holds both 

29# objects, so their ids cannot be given to other objects while it is kept. 

30_KEPT_PAIRS = 64 

31_differing_fields_of_pair: "OrderedDict[tuple[int, int], tuple[Any, Any, frozenset[FieldKey]]]" = ( 

32 OrderedDict() 

33) 

34 

35 

36class ResolutionSources(NamedTuple): 

37 """What one resolved snapshot of a state is computed from. 

38 

39 ``global_config`` is the global config (``objectstate.global_config``), ``None`` 

40 when there is none (the declared defaults are used then). ``ancestors`` are the 

41 ``(scope id, object)`` pairs of the states registered above the state, least 

42 specific first, without global config objects: the global config stands for 

43 them. 

44 """ 

45 

46 global_config: Any | None 

47 ancestors: tuple[tuple[str, Any], ...] 

48 

49 def same_objects_as(self, other: "ResolutionSources | None") -> bool: 

50 """Whether ``other`` holds the same objects at the same scopes.""" 

51 if other is None: 

52 return False 

53 if self.global_config is not other.global_config: 

54 return False 

55 if len(self.ancestors) != len(other.ancestors): 

56 return False 

57 for mine, theirs in zip(self.ancestors, other.ancestors): # on every resolved read 

58 if mine[1] is not theirs[1] or mine[0] != theirs[0]: 

59 return False 

60 return True 

61 

62 def fields_that_differ(self, earlier: "ResolutionSources | None") -> frozenset[FieldKey] | None: 

63 """The fields whose stored value differs between ``earlier`` and these sources. 

64 

65 ``None`` means every field may differ: there is no ``earlier``, other scopes 

66 have a registered state, or a global config was set where none was (or the 

67 reverse). 

68 """ 

69 if earlier is None: 

70 return None 

71 if (earlier.global_config is None) != (self.global_config is None): 

72 return None 

73 if tuple(scope for scope, _ in earlier.ancestors) != tuple( 

74 scope for scope, _ in self.ancestors 

75 ): 

76 return None 

77 differing: frozenset[FieldKey] = frozenset() 

78 if self.global_config is not earlier.global_config: 

79 differing |= _fields_that_differ(earlier.global_config, self.global_config) 

80 for (_, before), (_, now) in zip(earlier.ancestors, self.ancestors): 

81 if before is not now: 

82 differing |= _fields_that_differ(before, now) 

83 return differing 

84 

85 

86def _fields_that_differ(before: Any, now: Any) -> frozenset[FieldKey]: 

87 """``(config type, field name)`` pairs stored differently in two layer objects.""" 

88 pair = (id(before), id(now)) 

89 kept = _differing_fields_of_pair.get(pair) 

90 if kept is not None and kept[0] is before and kept[1] is now: 

91 return kept[2] 

92 differing = frozenset(_compare_layer_objects(before, now)) 

93 _differing_fields_of_pair[pair] = (before, now, differing) 

94 while len(_differing_fields_of_pair) > _KEPT_PAIRS: 

95 _differing_fields_of_pair.popitem(last=False) 

96 return differing 

97 

98 

99def _compare_layer_objects(before: Any, now: Any) -> set[FieldKey]: 

100 from objectstate.context_manager import extract_all_configs 

101 

102 configs_before = extract_all_configs(before) 

103 configs_now = extract_all_configs(now) 

104 held_before = {id(config) for config in configs_before.values()} 

105 held_now = {id(config) for config in configs_now.values()} 

106 differing: set[FieldKey] = set() 

107 for config_type in configs_before.keys() | configs_now.keys(): 

108 config_before = configs_before.get(config_type) 

109 config_now = configs_now.get(config_type) 

110 if config_before is config_now: 

111 continue 

112 names = { 

113 f.name for config in (config_before, config_now) if config is not None 

114 for f in fields(config) 

115 } 

116 if config_before is None or config_now is None: 

117 # A layer that starts or stops holding a config type changes where an 

118 # unset field is reported to live, whatever the values are. 

119 differing.update((config_type, name) for name in names) 

120 continue 

121 for name in names: 

122 value_before = _stored(config_before, name) 

123 value_now = _stored(config_now, name) 

124 if id(value_before) in held_before and id(value_now) in held_now: 

125 continue # a nested config on both sides: compared under its own type 

126 if not _same_stored_value(value_before, value_now): 

127 differing.add((config_type, name)) 

128 return differing 

129 

130 

131def _stored(config: Any, name: str) -> Any: 

132 """The raw field of a config, without lazy resolution; ``None`` when absent.""" 

133 if config is None: 

134 return None 

135 try: 

136 return object.__getattribute__(config, name) 

137 except AttributeError: 

138 return None 

139 

140 

141def _same_stored_value(a: Any, b: Any) -> bool: 

142 """Equality of two stored values that never resolves a lazy field. 

143 

144 Dataclass instances are compared field by field on their stored values: their 

145 own ``__eq__`` reads attributes, which resolves lazy fields in the caller's 

146 context. A value whose comparison fails or is not a plain truth value counts 

147 as different. 

148 """ 

149 if a is b: 

150 return True 

151 if type(a) is not type(b): 

152 return False 

153 if is_dataclass(a) and not isinstance(a, type): 

154 return all(_same_stored_value(_stored(a, f.name), _stored(b, f.name)) for f in fields(a)) 

155 try: 

156 return bool(a == b) 

157 except Exception: 

158 return False