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
« 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.
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.
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.
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"""
22from collections import OrderedDict
23from dataclasses import fields, is_dataclass
24from typing import Any, NamedTuple
26FieldKey = tuple[type, str]
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)
36class ResolutionSources(NamedTuple):
37 """What one resolved snapshot of a state is computed from.
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 """
46 global_config: Any | None
47 ancestors: tuple[tuple[str, Any], ...]
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
62 def fields_that_differ(self, earlier: "ResolutionSources | None") -> frozenset[FieldKey] | None:
63 """The fields whose stored value differs between ``earlier`` and these sources.
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
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
99def _compare_layer_objects(before: Any, now: Any) -> set[FieldKey]:
100 from objectstate.context_manager import extract_all_configs
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
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
141def _same_stored_value(a: Any, b: Any) -> bool:
142 """Equality of two stored values that never resolves a lazy field.
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