ygg 0.1.44__py3-none-any.whl → 0.1.46__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,17 +4,16 @@ import heapq
4
4
  import itertools
5
5
  import threading
6
6
  import time
7
+ import pickle
7
8
  from collections.abc import MutableMapping, Iterator
8
9
  from dataclasses import dataclass
9
- from typing import Callable, Generic, Optional, TypeVar, Dict, Tuple
10
+ from typing import Callable, Generic, Optional, TypeVar, Dict, Tuple, Any, Mapping
10
11
 
11
12
  K = TypeVar("K")
12
13
  V = TypeVar("V")
13
14
 
14
15
 
15
- __all__ = [
16
- "ExpiringDict"
17
- ]
16
+ __all__ = ["ExpiringDict"]
18
17
 
19
18
 
20
19
  @dataclass(frozen=True)
@@ -27,12 +26,14 @@ class ExpiringDict(MutableMapping[K, V]):
27
26
  """
28
27
  Dict with per-key TTL expiration.
29
28
 
30
- - Uses time.monotonic() (safe against system clock changes)
31
- - O(log n) cleanup amortized via a min-heap of expirations
32
- - Overwrites are handled (stale heap entries are ignored)
33
- - Optional refresh_on_get: touching a key extends its TTL
29
+ Serialization note:
30
+ - Internally uses time.monotonic() for expires_at (great locally).
31
+ - For serialization, we store remaining TTLs so it can be reconstructed
32
+ in a different process with a different monotonic clock origin.
34
33
  """
35
34
 
35
+ _SER_VERSION = 1
36
+
36
37
  def __init__(
37
38
  self,
38
39
  default_ttl: Optional[float] = None,
@@ -41,12 +42,6 @@ class ExpiringDict(MutableMapping[K, V]):
41
42
  on_expire: Optional[Callable[[K, V], None]] = None,
42
43
  thread_safe: bool = False,
43
44
  ) -> None:
44
- """
45
- default_ttl: seconds, if provided used when ttl isn't passed to set()
46
- refresh_on_get: if True, get()/__getitem__ extends TTL using default_ttl
47
- on_expire: callback(key, value) called when an item is expired during cleanup
48
- thread_safe: wrap operations in an RLock (extra overhead, but safe)
49
- """
50
45
  self.default_ttl = default_ttl
51
46
  self.refresh_on_get = refresh_on_get
52
47
  self.on_expire = on_expire
@@ -55,24 +50,22 @@ class ExpiringDict(MutableMapping[K, V]):
55
50
  self._heap: list[Tuple[float, int, K]] = [] # (expires_at, seq, key)
56
51
  self._seq = itertools.count()
57
52
 
53
+ self._thread_safe = thread_safe
58
54
  self._lock = threading.RLock() if thread_safe else None
59
55
 
60
56
  def _now(self) -> float:
61
57
  return time.monotonic()
62
58
 
63
59
  def _with_lock(self):
64
- # tiny helper to avoid repeating if/else everywhere
65
60
  return self._lock or _NoopLock()
66
61
 
67
62
  def _prune(self) -> None:
68
- """Remove expired entries. Ignores stale heap rows from overwrites."""
69
63
  now = self._now()
70
64
  while self._heap and self._heap[0][0] <= now:
71
65
  exp, _, key = heapq.heappop(self._heap)
72
66
  entry = self._store.get(key)
73
67
  if entry is None:
74
68
  continue
75
- # Only expire if this heap expiry matches current entry expiry
76
69
  if entry.expires_at == exp:
77
70
  del self._store[key]
78
71
  if self.on_expire:
@@ -84,11 +77,9 @@ class ExpiringDict(MutableMapping[K, V]):
84
77
  if ttl is None:
85
78
  ttl = self.default_ttl
86
79
  if ttl is None:
87
- # no expiration
88
80
  expires_at = float("inf")
89
81
  else:
90
82
  if ttl <= 0:
91
- # immediate expiration: just delete if exists
92
83
  self._store.pop(key, None)
93
84
  return
94
85
  expires_at = self._now() + ttl
@@ -98,22 +89,19 @@ class ExpiringDict(MutableMapping[K, V]):
98
89
 
99
90
  # --- MutableMapping interface ---
100
91
  def __setitem__(self, key: K, value: V) -> None:
101
- # Uses default_ttl (if any)
102
92
  self.set(key, value, ttl=self.default_ttl)
103
93
 
104
94
  def __getitem__(self, key: K) -> V:
105
95
  with self._with_lock():
106
96
  self._prune()
107
- entry = self._store[key] # may raise KeyError
97
+ entry = self._store[key]
108
98
  if entry.expires_at <= self._now():
109
- # edge case: expired but not yet pruned (rare)
110
99
  del self._store[key]
111
100
  raise KeyError(key)
112
101
 
113
102
  if self.refresh_on_get:
114
103
  if self.default_ttl is None:
115
104
  raise ValueError("refresh_on_get=True requires default_ttl")
116
- # refresh TTL
117
105
  self.set(key, entry.value, ttl=self.default_ttl)
118
106
  return entry.value
119
107
 
@@ -129,7 +117,6 @@ class ExpiringDict(MutableMapping[K, V]):
129
117
  with self._with_lock():
130
118
  self._prune()
131
119
  del self._store[key]
132
- # heap keeps stale rows; they'll be ignored during prune
133
120
 
134
121
  def __iter__(self) -> Iterator[K]:
135
122
  with self._with_lock():
@@ -150,7 +137,6 @@ class ExpiringDict(MutableMapping[K, V]):
150
137
  return False
151
138
 
152
139
  def cleanup(self) -> int:
153
- """Force prune and return number of remaining items."""
154
140
  with self._with_lock():
155
141
  self._prune()
156
142
  return len(self._store)
@@ -170,6 +156,109 @@ class ExpiringDict(MutableMapping[K, V]):
170
156
  self._prune()
171
157
  return [e.value for e in self._store.values()]
172
158
 
159
+ # ----------------------------
160
+ # Serialization / Pickling
161
+ # ----------------------------
162
+ def to_state(self) -> dict[str, Any]:
163
+ """
164
+ Returns a JSON-ish friendly state (assuming keys/values are JSON-friendly).
165
+ Stores remaining TTL (seconds) per key; None => no expiration.
166
+ Expired entries are dropped.
167
+ """
168
+ with self._with_lock():
169
+ self._prune()
170
+ now = self._now()
171
+
172
+ items: list[tuple[Any, Any, Optional[float]]] = []
173
+ for k, e in self._store.items():
174
+ if e.expires_at == float("inf"):
175
+ rem = None
176
+ else:
177
+ rem = e.expires_at - now
178
+ if rem <= 0:
179
+ continue
180
+ items.append((k, e.value, rem))
181
+
182
+ return {
183
+ "v": self._SER_VERSION,
184
+ "default_ttl": self.default_ttl,
185
+ "refresh_on_get": self.refresh_on_get,
186
+ "thread_safe": self._thread_safe,
187
+ # NOTE: on_expire is intentionally not serialized
188
+ "items": items,
189
+ }
190
+
191
+ @classmethod
192
+ def from_state(
193
+ cls,
194
+ state: Mapping[str, Any],
195
+ *,
196
+ on_expire: Optional[Callable[[K, V], None]] = None,
197
+ ) -> "ExpiringDict[K, V]":
198
+ """
199
+ Rebuild from state produced by to_state().
200
+ """
201
+ if state.get("v") != cls._SER_VERSION:
202
+ raise ValueError(f"Unsupported serialized version: {state.get('v')}")
203
+
204
+ d = cls(
205
+ default_ttl=state.get("default_ttl"),
206
+ refresh_on_get=bool(state.get("refresh_on_get", False)),
207
+ on_expire=on_expire,
208
+ thread_safe=bool(state.get("thread_safe", False)),
209
+ )
210
+
211
+ now = d._now()
212
+ items = state.get("items", [])
213
+ for k, v, rem in items:
214
+ if rem is None:
215
+ expires_at = float("inf")
216
+ else:
217
+ if rem <= 0:
218
+ continue
219
+ expires_at = now + float(rem)
220
+
221
+ d._store[k] = _Entry(value=v, expires_at=expires_at)
222
+ heapq.heappush(d._heap, (expires_at, next(d._seq), k))
223
+
224
+ return d
225
+
226
+ def to_bytes(self, protocol: int = pickle.HIGHEST_PROTOCOL) -> bytes:
227
+ """
228
+ Pickle to bytes using the portable state representation.
229
+ """
230
+ return pickle.dumps(self.to_state(), protocol=protocol)
231
+
232
+ @classmethod
233
+ def from_bytes(
234
+ cls,
235
+ data: bytes,
236
+ *,
237
+ on_expire: Optional[Callable[[K, V], None]] = None,
238
+ ) -> "ExpiringDict[K, V]":
239
+ state = pickle.loads(data)
240
+ return cls.from_state(state, on_expire=on_expire)
241
+
242
+ def __getstate__(self) -> dict[str, Any]:
243
+ # Called by pickle
244
+ return self.to_state()
245
+
246
+ def __setstate__(self, state: dict[str, Any]) -> None:
247
+ # Called by pickle; rebuild "self" in-place
248
+ rebuilt = self.from_state(state, on_expire=None)
249
+
250
+ # Copy rebuilt internals into self (keep pickling contract)
251
+ self.default_ttl = rebuilt.default_ttl
252
+ self.refresh_on_get = rebuilt.refresh_on_get
253
+ self.on_expire = None # not serialized
254
+
255
+ self._store = rebuilt._store
256
+ self._heap = rebuilt._heap
257
+ self._seq = rebuilt._seq
258
+
259
+ self._thread_safe = rebuilt._thread_safe
260
+ self._lock = threading.RLock() if self._thread_safe else None
261
+
173
262
 
174
263
  class _NoopLock:
175
264
  def __enter__(self): return self
yggdrasil/version.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.1.44"
1
+ __version__ = "0.1.46"
File without changes