|
1 | | -import gc |
2 | | -import sys |
3 | | -from weakref import WeakValueDictionary |
| 1 | +""" |
| 2 | +Registry of the reactive state that observ keeps for each wrapped |
| 3 | +target object. |
| 4 | +
|
| 5 | +For every target that is wrapped by one or more proxies there is a |
| 6 | +single TargetDep: the Dep for the container as a whole, which also |
| 7 | +owns everything else observ needs to know about the target (the |
| 8 | +per-key deps and the proxies that wrap it). |
| 9 | +
|
| 10 | +Lifetimes are managed purely by reference counting: |
| 11 | +
|
| 12 | +- Every Proxy holds a strong reference to the TargetDep of its |
| 13 | + target (Proxy.__dep__). |
| 14 | +- Every Watcher that depends on a target holds a strong reference |
| 15 | + to its TargetDep, or to one of its KeyDeps which in turn hold |
| 16 | + their TargetDep, for as long as the dependency lasts. |
| 17 | +- The registry itself only holds weak references, so as soon as the |
| 18 | + last proxy and the last interested watcher are gone, the TargetDep |
| 19 | + (and with it observ's reference to the target) is destroyed and |
| 20 | + its entry is removed from the registry by a weakref callback. |
| 21 | +
|
| 22 | +The registry is keyed on id(target), because the plain containers |
| 23 | +(dict, list, set) do not support weak references and are not |
| 24 | +(reliably) hashable. This is safe against id reuse, because a |
| 25 | +TargetDep keeps its target alive: an id can only be recycled after |
| 26 | +the TargetDep for its previous target has already been destroyed |
| 27 | +and has removed itself from the registry. |
| 28 | +""" |
| 29 | + |
| 30 | +from weakref import WeakValueDictionary, ref |
4 | 31 |
|
5 | 32 | from .dep import Dep |
6 | 33 |
|
7 | 34 |
|
| 35 | +class TargetDep(Dep): |
| 36 | + """ |
| 37 | + The Dep for a target container as a whole. There is at most one |
| 38 | + TargetDep per wrapped target, shared by all proxies that wrap it, |
| 39 | + so that watchers and mutations always meet on the same Dep no |
| 40 | + matter which proxy they go through. |
| 41 | + """ |
| 42 | + |
| 43 | + __slots__ = ("keydeps", "proxies", "target") |
| 44 | + |
| 45 | + def __init__(self, target): |
| 46 | + super().__init__() |
| 47 | + self.target = target |
| 48 | + # Per-key deps (dict targets only). Starts out as None and is |
| 49 | + # only materialized (as a WeakValueDictionary) when a key is |
| 50 | + # read with dependency tracking active, since constructing a |
| 51 | + # WeakValueDictionary is relatively expensive. The values are |
| 52 | + # kept alive by the watchers that depend on them: a keydep |
| 53 | + # without subscribers has nobody to notify, so it can be |
| 54 | + # recreated freely whenever the key is read again |
| 55 | + self.keydeps = None |
| 56 | + # Weakrefs to the proxies that wrap the target, |
| 57 | + # keyed on (readonly, shallow) |
| 58 | + self.proxies = {} |
| 59 | + |
| 60 | + def keydep(self, key): |
| 61 | + """ |
| 62 | + Returns the dep for the given key, creating it if needed. |
| 63 | + Note that the keydeps mapping is weak: the returned dep stays |
| 64 | + registered only for as long as the caller (or a subscribed |
| 65 | + watcher) holds a reference to it. |
| 66 | + """ |
| 67 | + keydeps = self.keydeps |
| 68 | + if keydeps is None: |
| 69 | + keydeps = self.keydeps = WeakValueDictionary() |
| 70 | + else: |
| 71 | + keydep = keydeps.get(key) |
| 72 | + if keydep is not None: |
| 73 | + return keydep |
| 74 | + keydep = KeyDep(self) |
| 75 | + keydeps[key] = keydep |
| 76 | + return keydep |
| 77 | + |
| 78 | + def register_proxy(self, config, proxy): |
| 79 | + """ |
| 80 | + Registers the proxy as the proxy that wraps the target with |
| 81 | + the given (readonly, shallow) configuration. There can be only |
| 82 | + one proxy per configuration. |
| 83 | + """ |
| 84 | + proxies = self.proxies |
| 85 | + existing = proxies.get(config) |
| 86 | + if existing is not None and existing() is not None: |
| 87 | + raise RuntimeError("Proxy with existing configuration already in db") |
| 88 | + |
| 89 | + def remove(weak_proxy, proxies=proxies, config=config): |
| 90 | + if proxies.get(config) is weak_proxy: |
| 91 | + del proxies[config] |
| 92 | + |
| 93 | + proxies[config] = ref(proxy, remove) |
| 94 | + |
| 95 | + def get_proxy(self, config): |
| 96 | + """ |
| 97 | + Returns the proxy that wraps the target with the given |
| 98 | + (readonly, shallow) configuration, or None if there is none. |
| 99 | + """ |
| 100 | + weak_proxy = self.proxies.get(config) |
| 101 | + if weak_proxy is None: |
| 102 | + return None |
| 103 | + return weak_proxy() |
| 104 | + |
| 105 | + |
| 106 | +class KeyDep(Dep): |
| 107 | + """ |
| 108 | + The Dep for a single key of a target. It holds a strong reference |
| 109 | + to the TargetDep that owns it, so that a watcher that depends on |
| 110 | + just a key still keeps the target's registry entry (and thereby |
| 111 | + the identity of its deps) alive. |
| 112 | + """ |
| 113 | + |
| 114 | + __slots__ = ("owner",) |
| 115 | + |
| 116 | + def __init__(self, owner): |
| 117 | + super().__init__() |
| 118 | + self.owner = owner |
| 119 | + |
| 120 | + |
8 | 121 | class ProxyDb: |
9 | 122 | """ |
10 | | - Collection of proxies, tracked by the id of the object that they wrap. |
11 | | - Each time a Proxy is instantiated, it will register itself for the |
12 | | - wrapped object. And when a Proxy is deleted, then it will unregister. |
13 | | - When the last proxy that wraps an object is removed, it is uncertain |
14 | | - what happens to the wrapped object, so in that case the object id is |
15 | | - removed from the collection. |
| 123 | + Weak registry of TargetDeps, keyed on the id of the target object |
| 124 | + that they describe. Entries remove themselves when their TargetDep |
| 125 | + is destroyed. |
16 | 126 | """ |
17 | 127 |
|
18 | 128 | __slots__ = ("db",) |
19 | 129 |
|
20 | 130 | def __init__(self): |
| 131 | + # id(target) -> weakref to the TargetDep for that target |
21 | 132 | self.db = {} |
22 | | - gc.callbacks.append(self.cleanup) |
23 | 133 |
|
24 | | - def cleanup(self, phase, info): |
| 134 | + def target_dep(self, target): |
25 | 135 | """ |
26 | | - Callback for garbage collector to cleanup the db for targets |
27 | | - that have no other references outside of the db |
| 136 | + Returns the TargetDep for the given target, creating it (and |
| 137 | + registering it) if there is none yet. |
28 | 138 | """ |
29 | | - if phase != "stop": |
30 | | - # Ref counts are only stable after collection |
31 | | - return |
32 | | - |
33 | | - if info["generation"] != 2: |
34 | | - # Only cleanup on full collection. Python GC runs in C mostly, |
35 | | - # and this callback runs in Python land, so we want to minimize |
36 | | - # the overhead as much as possible. The full collection happens |
37 | | - # rarely compared to the other generations: |
38 | | - # - gen 0 triggers constantly |
39 | | - # - gen 1 is less frequent but still too often |
40 | | - # - gen 2 is the full collection which happens rarely |
41 | | - return |
42 | | - |
43 | | - if not (db := self.db): |
44 | | - # Early exit: Nothing to cleanup |
45 | | - # This happens when observ is imported but not used |
46 | | - # For example via transient dependencies or unused codepaths |
47 | | - # This if statement avoids creating an iterator just to find out |
48 | | - # it is empty |
49 | | - return |
50 | | - |
51 | | - getrefcount = sys.getrefcount # Local lookup is faster |
52 | | - |
53 | | - # First collect keys to delete so we don't have to modify db while iterating |
54 | | - # Use a list comprehension so we don't have to call .append in a loop |
55 | | - keys_to_delete = [ |
56 | | - key |
57 | | - for key, value in db.items() |
58 | | - # Refs: |
59 | | - # - sys.getrefcount |
60 | | - # - ref in db item |
61 | | - # If 2: We are the last to hold a reference! |
62 | | - if getrefcount(value["target"]) <= 2 |
63 | | - ] |
64 | | - |
65 | | - # Only enter this for loop if there is something to delete, this avoids |
66 | | - # creating an iterator only to find out there is nothing to delete. |
67 | | - if keys_to_delete: |
68 | | - for key in keys_to_delete: |
69 | | - del db[key] |
70 | | - |
71 | | - def reference(self, proxy): |
72 | | - """ |
73 | | - Adds a reference to the collection for the wrapped object's id |
74 | | - """ |
75 | | - target = proxy.__target__ |
| 139 | + db = self.db |
76 | 140 | obj_id = id(target) |
| 141 | + weak_dep = db.get(obj_id) |
| 142 | + if weak_dep is not None: |
| 143 | + dep = weak_dep() |
| 144 | + if dep is not None: |
| 145 | + return dep |
77 | 146 |
|
78 | | - entry = self.db.get(obj_id) |
79 | | - if entry is None: |
80 | | - attrs = {} |
81 | | - if isinstance(target, dict): |
82 | | - attrs["dep"] = Dep() |
83 | | - # keydeps are created lazily: when a key is read |
84 | | - # (with dependency tracking active) or written |
85 | | - attrs["keydep"] = {} |
86 | | - elif isinstance(target, (list, set)): |
87 | | - attrs["dep"] = Dep() |
88 | | - entry = { |
89 | | - "target": target, |
90 | | - "attrs": attrs, # dep, keydep |
91 | | - # keyed on tuple(readonly, shallow) |
92 | | - "proxies": WeakValueDictionary(), |
93 | | - } |
94 | | - self.db[obj_id] = entry |
95 | | - |
96 | | - # Use setdefault to put the proxy in the proxies dict. If there |
97 | | - # was an existing value, it will return that instead. There shouldn't |
98 | | - # be an existing value, so we can compare the objects to see if we |
99 | | - # should raise an exception. |
100 | | - # Seems to be a tiny bit faster than checking beforehand if |
101 | | - # there is already an existing value in the proxies dict |
102 | | - result = entry["proxies"].setdefault( |
103 | | - (proxy.__readonly__, proxy.__shallow__), proxy |
104 | | - ) |
105 | | - if result is not proxy: |
106 | | - raise RuntimeError("Proxy with existing configuration already in db") |
| 147 | + dep = TargetDep(target) |
107 | 148 |
|
108 | | - def dereference(self, proxy): |
109 | | - """ |
110 | | - Removes a reference from the database for the given proxy |
111 | | - """ |
112 | | - obj_id = id(proxy.__target__) |
113 | | - entry = self.db.get(obj_id) |
114 | | - if entry is None: |
115 | | - # When there are failing tests, it might happen that proxies |
116 | | - # are garbage collected at a point where the proxy_db is already |
117 | | - # cleared. That's why we need this check here. |
118 | | - # See fixture [clear_proxy_db](/tests/conftest.py:clear_proxy_db) |
119 | | - # for more info. |
120 | | - return |
121 | | - |
122 | | - # The given proxy is the last proxy in the WeakValueDictionary, |
123 | | - # so now is a good moment to see if can remove clean the deps |
124 | | - # for the target object |
125 | | - if len(entry["proxies"]) == 1: |
126 | | - ref_count = sys.getrefcount(entry["target"]) |
127 | | - # Ref count is still 3 here because of the reference |
128 | | - # through proxy.__target__ |
129 | | - if ref_count <= 3: |
130 | | - # We are the last to hold a reference! |
131 | | - del self.db[obj_id] |
132 | | - |
133 | | - def attrs(self, proxy): |
134 | | - return self.db[id(proxy.__target__)]["attrs"] |
| 149 | + def remove(weak_dep, db=db, obj_id=obj_id): |
| 150 | + # Guard against the entry having been replaced in the |
| 151 | + # meantime, so a stale callback can't remove a live entry |
| 152 | + if db.get(obj_id) is weak_dep: |
| 153 | + del db[obj_id] |
| 154 | + |
| 155 | + db[obj_id] = ref(dep, remove) |
| 156 | + return dep |
135 | 157 |
|
136 | 158 | def get_proxy(self, target, readonly=False, shallow=False): |
137 | 159 | """ |
138 | | - Returns a proxy from the collection for the given object and configuration. |
139 | | - Will return None if there is no proxy for the object's id. |
| 160 | + Returns the proxy with the given configuration for the given |
| 161 | + target. Will return None if there is no such proxy. |
140 | 162 | """ |
141 | | - try: |
142 | | - return self.db[id(target)]["proxies"].get((readonly, shallow)) |
143 | | - except KeyError: |
| 163 | + weak_dep = self.db.get(id(target)) |
| 164 | + if weak_dep is None: |
| 165 | + return None |
| 166 | + dep = weak_dep() |
| 167 | + if dep is None: |
144 | 168 | return None |
| 169 | + return dep.get_proxy((readonly, shallow)) |
145 | 170 |
|
146 | 171 |
|
147 | 172 | # Create a global proxy collection |
|
0 commit comments