persidict 0.38.0__py3-none-any.whl → 0.104.0__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.

Potentially problematic release.


This version of persidict might be problematic. Click here for more details.

persidict/persi_dict.py CHANGED
@@ -23,37 +23,24 @@ from parameterizable import ParameterizableClass, sort_dict_by_keys
23
23
  from typing import Any, Sequence, Optional
24
24
  from collections.abc import MutableMapping
25
25
 
26
- from .jokers import KEEP_CURRENT, DELETE_CURRENT, Joker
26
+ from . import NonEmptySafeStrTuple
27
+ from .singletons import (KEEP_CURRENT, DELETE_CURRENT, Joker,
28
+ CONTINUE_NORMAL_EXECUTION, StatusFlag, EXECUTION_IS_COMPLETE,
29
+ ETagHasNotChangedFlag, ETAG_HAS_NOT_CHANGED)
30
+ from .safe_chars import contains_unsafe_chars
27
31
  from .safe_str_tuple import SafeStrTuple
28
32
 
29
33
  PersiDictKey = SafeStrTuple | Sequence[str] | str
34
+ NonEmptyPersiDictKey = NonEmptySafeStrTuple | Sequence[str] | str
30
35
  """A value which can be used as a key for PersiDict.
31
36
 
32
- PersiDict instances accept keys in the form of SafeStrTuple,
33
- or a string, or a sequence of strings.
37
+ PersiDict instances accept keys in the form of (NonEmpty)SafeStrTuple,
38
+ or a string, or a (non-empty) sequence of strings.
34
39
  The characters within strings must be URL/filename-safe.
35
40
  If a string (or a sequence of strings) is passed to a PersiDict as a key,
36
41
  it will be automatically converted into SafeStrTuple.
37
42
  """
38
43
 
39
-
40
- def non_empty_persidict_key(*args) -> SafeStrTuple:
41
- """Create a non-empty SafeStrTuple from the given arguments.
42
- This is a convenience function that ensures the resulting SafeStrTuple is
43
- not empty, raising a KeyError if it is.
44
- Args:
45
- *args: Arguments to pass to SafeStrTuple constructor.
46
- Returns:
47
- SafeStrTuple: A non-empty SafeStrTuple instance.
48
- Raises:
49
- KeyError: If the resulting SafeStrTuple is empty.
50
- """
51
- result = SafeStrTuple(*args)
52
- if len(result) == 0:
53
- raise KeyError("Key cannot be empty")
54
- return result
55
-
56
-
57
44
  class PersiDict(MutableMapping, ParameterizableClass):
58
45
  """Abstract dict-like interface for durable key-value stores.
59
46
 
@@ -62,52 +49,65 @@ class PersiDict(MutableMapping, ParameterizableClass):
62
49
  similar to Python's dict but does not guarantee insertion order and adds
63
50
  persistence-specific helpers (e.g., timestamp()).
64
51
 
65
- Attributes:
66
- immutable_items (bool):
67
- If True, items are write-once: existing values cannot be modified or
68
- deleted.
69
- digest_len (int):
70
- Length of a base32 MD5 digest fragment used to suffix each key
71
- component to avoid collisions on case-insensitive filesystems. 0
72
- disables suffixing.
52
+ Attributes (can't be changed after initialization):
53
+ append_only (bool):
54
+ If True, items are immutable and non-removable: existing values
55
+ cannot be modified or deleted.
73
56
  base_class_for_values (Optional[type]):
74
57
  Optional base class that all values must inherit from. If None, any
75
58
  type is accepted.
59
+ serialization_format (str):
60
+ File extension/format for stored values (e.g., "pkl", "json").
76
61
  """
77
62
 
78
- digest_len:int
79
- immutable_items:bool
63
+ append_only:bool
80
64
  base_class_for_values:Optional[type]
65
+ serialization_format:str
81
66
 
82
67
  def __init__(self,
83
- immutable_items: bool = False,
84
- digest_len: int = 8,
85
- base_class_for_values: Optional[type] = None,
68
+ append_only: bool = False,
69
+ base_class_for_values: type|None = None,
70
+ serialization_format: str = "pkl",
86
71
  *args, **kwargs):
87
72
  """Initialize base parameters shared by all persistent dictionaries.
88
73
 
89
74
  Args:
90
- immutable_items (bool): If True, items cannot be modified or deleted.
75
+ append_only (bool): If True, items cannot be modified or deleted.
91
76
  Defaults to False.
92
- digest_len (int): Number of hash characters to append to key components
93
- to avoid case-insensitive collisions. Must be non-negative.
94
- Defaults to 8.
95
77
  base_class_for_values (Optional[type]): Optional base class that values
96
78
  must inherit from. If None, values are not type-restricted.
97
79
  Defaults to None.
80
+ serialization_format (str): File extension/format for stored values.
81
+ Defaults to "pkl".
98
82
  *args: Additional positional arguments (ignored in base class, reserved
99
83
  for subclasses).
100
84
  **kwargs: Additional keyword arguments (ignored in base class, reserved
101
85
  for subclasses).
102
86
 
103
87
  Raises:
104
- ValueError: If digest_len is negative.
105
- """
106
- self.digest_len = int(digest_len)
107
- if digest_len < 0:
108
- raise ValueError("digest_len must be non-negative")
109
- self.immutable_items = bool(immutable_items)
88
+ ValueError: If serialization_format is an empty string,
89
+ or contains unsafe characters, or not 'jason' or 'pkl'
90
+ for non-string values.
91
+
92
+ TypeError: If base_class_for_values is not a type or None.
93
+ """
94
+
95
+ self._append_only = bool(append_only)
96
+
97
+ if len(serialization_format) == 0:
98
+ raise ValueError("serialization_format must be a non-empty string")
99
+ if contains_unsafe_chars(serialization_format):
100
+ raise ValueError("serialization_format must contain only URL/filename-safe characters")
101
+ self.serialization_format = str(serialization_format)
102
+
103
+ if not isinstance(base_class_for_values, (type, type(None))):
104
+ raise TypeError("base_class_for_values must be a type or None")
105
+ if (base_class_for_values is None or
106
+ not issubclass(base_class_for_values, str)):
107
+ if serialization_format not in {"json", "pkl"}:
108
+ raise ValueError("For non-string values serialization_format must be either 'pkl' or 'json'.")
110
109
  self.base_class_for_values = base_class_for_values
110
+
111
111
  ParameterizableClass.__init__(self)
112
112
 
113
113
 
@@ -120,41 +120,38 @@ class PersiDict(MutableMapping, ParameterizableClass):
120
120
  built-in dict.
121
121
  """
122
122
  params = dict(
123
- immutable_items=self.immutable_items,
124
- digest_len=self.digest_len,
125
- base_class_for_values=self.base_class_for_values
123
+ append_only=self.append_only,
124
+ base_class_for_values=self.base_class_for_values,
125
+ serialization_format=self.serialization_format
126
126
  )
127
127
  sorted_params = sort_dict_by_keys(params)
128
128
  return sorted_params
129
129
 
130
+ def __copy__(self) -> 'PersiDict':
131
+ """Return a shallow copy of the dictionary.
130
132
 
131
- @property
132
- @abstractmethod
133
- def base_url(self):
134
- """Base URL identifying the storage location.
133
+ This creates a new PersiDict instance with the same parameters, pointing
134
+ to the same underlying storage. This is analogous to `dict.copy()`.
135
135
 
136
136
  Returns:
137
- str: A URL-like string (e.g., s3://bucket/prefix or file://...).
138
-
139
- Raises:
140
- NotImplementedError: Must be provided by subclasses.
137
+ PersiDict: A new PersiDict instance that is a shallow copy of this one.
141
138
  """
142
- raise NotImplementedError
139
+ if type(self) is PersiDict:
140
+ raise NotImplementedError("PersiDict is an abstract base class"
141
+ " and cannot be copied directly")
142
+ params = self.get_params()
143
+ return self.__class__(**params)
143
144
 
144
145
 
145
146
  @property
146
- @abstractmethod
147
- def base_dir(self):
148
- """Base directory on the local filesystem, if applicable.
147
+ def append_only(self) -> bool:
148
+ """Whether the store is append-only.
149
149
 
150
150
  Returns:
151
- str: Path to a local base directory used by the store.
152
-
153
- Raises:
154
- NotImplementedError: Must be provided by subclasses that use local
155
- storage.
151
+ bool: True if the store is append-only (contains immutable items
152
+ that cannot be modified or deleted), False otherwise.
156
153
  """
157
- raise NotImplementedError
154
+ return self._append_only
158
155
 
159
156
 
160
157
  def __repr__(self) -> str:
@@ -178,7 +175,7 @@ class PersiDict(MutableMapping, ParameterizableClass):
178
175
 
179
176
 
180
177
  @abstractmethod
181
- def __contains__(self, key:PersiDictKey) -> bool:
178
+ def __contains__(self, key:NonEmptyPersiDictKey) -> bool:
182
179
  """Check whether a key exists in the store.
183
180
 
184
181
  Args:
@@ -187,13 +184,40 @@ class PersiDict(MutableMapping, ParameterizableClass):
187
184
  Returns:
188
185
  bool: True if key exists, False otherwise.
189
186
  """
190
- if type(self) is PersiDict:
191
- raise NotImplementedError("PersiDict is an abstract base class"
192
- " and cannot check items directly")
187
+ raise NotImplementedError("PersiDict is an abstract base class"
188
+ " and cannot check items directly")
189
+
190
+
191
+ def get_item_if_etag_changed(self, key: NonEmptyPersiDictKey, etag: str | None
192
+ ) -> tuple[Any, str|None]|ETagHasNotChangedFlag:
193
+ """Retrieve the value for a key only if its ETag has changed.
194
+
195
+ This method is absent in the original dict API.
196
+ By default, the timestamp is used in lieu of ETag.
197
+
198
+ Args:
199
+ key: Dictionary key (string or sequence of strings)
200
+ or NonEmptySafeStrTuple.
201
+ etag: The ETag value to compare against.
202
+
203
+ Returns:
204
+ tuple[Any, str|None] | ETagHasNotChangedFlag: The deserialized
205
+ value if the ETag has changed, along with the new ETag,
206
+ or ETAG_HAS_NOT_CHANGED if it matches the provided etag.
207
+
208
+ Raises:
209
+ KeyError: If the key does not exist.
210
+ """
211
+ key = NonEmptySafeStrTuple(key)
212
+ current_etag = self.etag(key)
213
+ if etag == current_etag:
214
+ return ETAG_HAS_NOT_CHANGED
215
+ else:
216
+ return self[key], current_etag
193
217
 
194
218
 
195
219
  @abstractmethod
196
- def __getitem__(self, key:PersiDictKey) -> Any:
220
+ def __getitem__(self, key:NonEmptyPersiDictKey) -> Any:
197
221
  """Retrieve the value for a key.
198
222
 
199
223
  Args:
@@ -202,98 +226,167 @@ class PersiDict(MutableMapping, ParameterizableClass):
202
226
  Returns:
203
227
  Any: The stored value.
204
228
  """
205
- if type(self) is PersiDict:
206
- raise NotImplementedError("PersiDict is an abstract base class"
207
- " and cannot retrieve items directly")
229
+ raise NotImplementedError("PersiDict is an abstract base class"
230
+ " and cannot retrieve items directly")
208
231
 
209
232
 
210
- def __setitem__(self, key:PersiDictKey, value:Any):
211
- """Set the value for a key.
212
233
 
213
- Special values KEEP_CURRENT and DELETE_CURRENT are interpreted as
214
- commands to keep or delete the current value respectively.
234
+ def _process_setitem_args(self, key: NonEmptyPersiDictKey, value: Any
235
+ ) -> StatusFlag:
236
+ """Perform the first steps for setting an item.
215
237
 
216
238
  Args:
217
- key: Key (string or sequence of strings) or SafeStrTuple.
218
- value: Value to store, or a Joker command.
239
+ key: Dictionary key (string or sequence of strings
240
+ or NonEmptySafeStrTuple).
241
+ value: Value to store, or a joker command (KEEP_CURRENT or
242
+ DELETE_CURRENT).
219
243
 
220
244
  Raises:
221
- KeyError: If attempting to modify an existing key when
222
- immutable_items is True.
223
- NotImplementedError: Subclasses must implement actual writing.
245
+ KeyError: If attempting to modify an existing item when
246
+ append_only is True.
247
+ TypeError: If the value is a PersiDict instance or does not match
248
+ the required base_class_for_values when specified.
249
+
250
+ Returns:
251
+ StatusFlag: CONTINUE_NORMAL_EXECUTION if the caller should
252
+ proceed with storing the value; EXECUTION_IS_COMPLETE if a
253
+ joker command was processed and no further action is needed.
224
254
  """
255
+
225
256
  if value is KEEP_CURRENT:
226
- return
227
- elif self.immutable_items:
228
- if key in self:
229
- raise KeyError("Can't modify an immutable key-value pair")
257
+ return EXECUTION_IS_COMPLETE
258
+ elif self.append_only and (value is DELETE_CURRENT or key in self):
259
+ raise KeyError("Can't modify an immutable key-value pair")
260
+ elif isinstance(value, PersiDict):
261
+ raise TypeError("Cannot store a PersiDict instance directly")
230
262
 
231
- key = non_empty_persidict_key(key)
263
+ key = NonEmptySafeStrTuple(key)
232
264
 
233
265
  if value is DELETE_CURRENT:
234
- self.delete_if_exists(key)
235
- return
266
+ self.discard(key)
267
+ return EXECUTION_IS_COMPLETE
236
268
 
237
269
  if self.base_class_for_values is not None:
238
270
  if not isinstance(value, self.base_class_for_values):
239
271
  raise TypeError(f"Value must be an instance of"
240
272
  f" {self.base_class_for_values.__name__}")
241
273
 
242
- if type(self) is PersiDict:
243
- raise NotImplementedError("PersiDict is an abstract base class"
244
- " and cannot store items directly")
274
+ return CONTINUE_NORMAL_EXECUTION
245
275
 
246
276
 
247
- def __delitem__(self, key:PersiDictKey):
248
- """Delete a key and its value.
277
+ def set_item_get_etag(self, key: NonEmptyPersiDictKey, value: Any) -> str|None:
278
+ """Store a value for a key directly in the dict and return the new ETag.
279
+
280
+ Handles special joker values (KEEP_CURRENT, DELETE_CURRENT) for
281
+ conditional operations. Validates value types against base_class_for_values
282
+ if specified, then serializes and uploads directly to S3.
283
+
284
+ This method is absent in the original dict API.
285
+ By default, the timestamp is used in lieu of ETag.
286
+
287
+ Args:
288
+ key: Dictionary key (string or sequence of strings)
289
+ or NonEmptySafeStrTuple.
290
+ value: Value to store, or a joker command (KEEP_CURRENT or
291
+ DELETE_CURRENT).
292
+
293
+ Returns:
294
+ str|None: The ETag of the newly stored object,
295
+ or None if the ETag was not provided as a result of the operation.
296
+
297
+ Raises:
298
+ KeyError: If attempting to modify an existing item when
299
+ append_only is True.
300
+ TypeError: If the value is a PersiDict instance or does not match
301
+ the required base_class_for_values when specified.
302
+ """
303
+
304
+ key = NonEmptySafeStrTuple(key)
305
+ if self._process_setitem_args(key, value) is EXECUTION_IS_COMPLETE:
306
+ return None
307
+ self[key] = value
308
+ return self.etag(key)
309
+
310
+ @abstractmethod
311
+ def __setitem__(self, key:NonEmptyPersiDictKey, value:Any):
312
+ """Set the value for a key.
313
+
314
+ Special values KEEP_CURRENT and DELETE_CURRENT are interpreted as
315
+ commands to keep or delete the current value respectively.
249
316
 
250
317
  Args:
251
318
  key: Key (string or sequence of strings) or SafeStrTuple.
319
+ value: Value to store, or a Joker command.
252
320
 
253
321
  Raises:
254
- KeyError: If immutable_items is True.
255
- NotImplementedError: Subclasses must implement deletion.
322
+ KeyError: If attempting to modify an existing key when
323
+ append_only is True.
324
+ NotImplementedError: Subclasses must implement actual writing.
256
325
  """
257
- if self.immutable_items:
326
+ if self._process_setitem_args(key, value) is EXECUTION_IS_COMPLETE:
327
+ return
328
+
329
+ raise NotImplementedError("PersiDict is an abstract base class"
330
+ " and cannot store items directly")
331
+
332
+
333
+ def _process_delitem_args(self, key: NonEmptyPersiDictKey) -> None:
334
+ """Perform the first steps for deleting an item.
335
+
336
+ Args:
337
+ key: Dictionary key (string or sequence of strings
338
+ or NonEmptySafeStrTuple).
339
+ Raises:
340
+ KeyError: If attempting to delete an item when
341
+ append_only is True or if the key does not exist.
342
+ """
343
+ if self.append_only:
258
344
  raise KeyError("Can't delete an immutable key-value pair")
259
- if type(self) is PersiDict:
260
- raise NotImplementedError("PersiDict is an abstract base class"
261
- " and cannot delete items directly")
262
345
 
263
- key = non_empty_persidict_key(key)
346
+ key = NonEmptySafeStrTuple(key)
264
347
 
265
348
  if key not in self:
266
349
  raise KeyError(f"Key {key} not found")
267
350
 
268
351
 
352
+ @abstractmethod
353
+ def __delitem__(self, key:NonEmptyPersiDictKey):
354
+ """Delete a key and its value.
355
+
356
+ Args:
357
+ key: Key (string or sequence of strings) or SafeStrTuple.
358
+
359
+ Raises:
360
+ KeyError: If append_only is True or if the key does not exist.
361
+ NotImplementedError: Subclasses must implement deletion.
362
+ """
363
+ self._process_delitem_args(key)
364
+ raise NotImplementedError("PersiDict is an abstract base class"
365
+ " and cannot delete items directly")
366
+
367
+
269
368
  @abstractmethod
270
369
  def __len__(self) -> int:
271
370
  """Return the number of stored items.
272
371
 
273
372
  Returns:
274
373
  int: Number of key-value pairs.
374
+ Raises:
375
+ NotImplementedError: Subclasses must implement counting.
275
376
  """
276
- if type(self) is PersiDict:
277
- raise NotImplementedError("PersiDict is an abstract base class"
278
- " and cannot count items directly")
377
+ raise NotImplementedError("PersiDict is an abstract base class"
378
+ " and cannot count items directly")
279
379
 
280
380
 
281
- @abstractmethod
282
- def _generic_iter(self, result_type: set[str]) -> Any:
283
- """Underlying implementation for iterator helpers.
381
+ def _process_generic_iter_args(self, result_type: set[str]) -> None:
382
+ """Validate arguments for iterator helpers.
284
383
 
285
384
  Args:
286
385
  result_type: A set indicating desired fields among {'keys',
287
386
  'values', 'timestamps'}.
288
-
289
- Returns:
290
- Any: An iterator yielding keys, values, and/or timestamps based on
291
- result_type.
292
-
293
387
  Raises:
294
388
  TypeError: If result_type is not a set.
295
389
  ValueError: If result_type contains invalid entries or an invalid number of items.
296
- NotImplementedError: Subclasses must implement the concrete iterator.
297
390
  """
298
391
  if not isinstance(result_type, set):
299
392
  raise TypeError("result_type must be a set of strings")
@@ -304,9 +397,28 @@ class PersiDict(MutableMapping, ParameterizableClass):
304
397
  raise ValueError("result_type can only contain 'keys', 'values', 'timestamps'")
305
398
  if not (1 <= len(result_type & allowed) <= 3):
306
399
  raise ValueError("result_type must include at least one of 'keys', 'values', 'timestamps'")
307
- if type(self) is PersiDict:
308
- raise NotImplementedError("PersiDict is an abstract base class"
309
- " and cannot iterate items directly")
400
+
401
+
402
+ @abstractmethod
403
+ def _generic_iter(self, result_type: set[str]) -> Any:
404
+ """Underlying implementation for iterator helpers.
405
+
406
+ Args:
407
+ result_type: A set indicating desired fields among {'keys',
408
+ 'values', 'timestamps'}.
409
+
410
+ Returns:
411
+ Any: An iterator yielding keys, values, and/or timestamps based on
412
+ result_type.
413
+
414
+ Raises:
415
+ TypeError: If result_type is not a set.
416
+ ValueError: If result_type contains invalid entries or an invalid number of items.
417
+ NotImplementedError: Subclasses must implement the concrete iterator.
418
+ """
419
+ self._process_generic_iter_args(result_type)
420
+ raise NotImplementedError("PersiDict is an abstract base class"
421
+ " and cannot iterate items directly")
310
422
 
311
423
 
312
424
  def __iter__(self):
@@ -372,7 +484,7 @@ class PersiDict(MutableMapping, ParameterizableClass):
372
484
  return self._generic_iter({"keys", "values", "timestamps"})
373
485
 
374
486
 
375
- def setdefault(self, key: PersiDictKey, default: Any = None) -> Any:
487
+ def setdefault(self, key: NonEmptyPersiDictKey, default: Any = None) -> Any:
376
488
  """Insert key with default value if absent; return the current value.
377
489
 
378
490
  Behaves like the built-in dict.setdefault() method: if the key exists,
@@ -389,20 +501,19 @@ class PersiDict(MutableMapping, ParameterizableClass):
389
501
  Raises:
390
502
  TypeError: If default is a Joker command (KEEP_CURRENT/DELETE_CURRENT).
391
503
  """
392
- key = SafeStrTuple(key)
504
+ key = NonEmptySafeStrTuple(key)
393
505
  if isinstance(default, Joker):
394
506
  raise TypeError("default must be a regular value, not a Joker command")
395
- if key in self:
507
+ try:
396
508
  return self[key]
397
- else:
509
+ except KeyError:
398
510
  self[key] = default
399
511
  return default
400
512
 
401
-
402
513
  def __eq__(self, other: PersiDict) -> bool:
403
514
  """Compare dictionaries for equality.
404
515
 
405
- If other is a PersiDict instance, compares portable parameters for equality.
516
+ If other is a PersiDict instance, compares parameters for equality.
406
517
  Otherwise, attempts to compare as a mapping by comparing all keys and values.
407
518
 
408
519
  Args:
@@ -411,17 +522,21 @@ class PersiDict(MutableMapping, ParameterizableClass):
411
522
  Returns:
412
523
  bool: True if the dictionaries are considered equal, False otherwise.
413
524
  """
414
- if isinstance(other, PersiDict):
415
- #TODO: decide whether to keep this semantics
416
- return self.get_portable_params() == other.get_portable_params()
417
525
  try:
526
+ if type(self) is type(other) :
527
+ if self.get_params() == other.get_params():
528
+ return True
529
+ except:
530
+ pass
531
+
532
+ try: #TODO: refactor to improve performance
418
533
  if len(self) != len(other):
419
534
  return False
420
535
  for k in other.keys():
421
536
  if self[k] != other[k]:
422
537
  return False
423
538
  return True
424
- except:
539
+ except KeyError:
425
540
  return False
426
541
 
427
542
 
@@ -451,9 +566,9 @@ class PersiDict(MutableMapping, ParameterizableClass):
451
566
  """Remove all items from the dictionary.
452
567
 
453
568
  Raises:
454
- KeyError: If items are immutable (immutable_items is True).
569
+ KeyError: If the dictionary is append-only.
455
570
  """
456
- if self.immutable_items:
571
+ if self.append_only:
457
572
  raise KeyError("Can't delete an immutable key-value pair")
458
573
 
459
574
  for k in self.keys():
@@ -463,7 +578,7 @@ class PersiDict(MutableMapping, ParameterizableClass):
463
578
  pass
464
579
 
465
580
 
466
- def delete_if_exists(self, key:PersiDictKey) -> bool:
581
+ def discard(self, key: NonEmptyPersiDictKey) -> bool:
467
582
  """Delete an item without raising an exception if it doesn't exist.
468
583
 
469
584
  This method is absent in the original dict API.
@@ -475,36 +590,42 @@ class PersiDict(MutableMapping, ParameterizableClass):
475
590
  bool: True if the item existed and was deleted; False otherwise.
476
591
 
477
592
  Raises:
478
- KeyError: If items are immutable (immutable_items is True).
593
+ KeyError: If the dictionary is append-only.
479
594
  """
480
595
 
481
- if self.immutable_items:
596
+ if self.append_only:
482
597
  raise KeyError("Can't delete an immutable key-value pair")
483
598
 
484
- key = non_empty_persidict_key(key)
599
+ key = NonEmptySafeStrTuple(key)
485
600
 
486
- if key in self:
487
- try:
488
- del self[key]
489
- return True
490
- except:
491
- return False
492
- else:
601
+ try:
602
+ del self[key]
603
+ return True
604
+ except KeyError:
493
605
  return False
494
606
 
495
607
 
608
+ def delete_if_exists(self, key: NonEmptyPersiDictKey) -> bool:
609
+ """Backward-compatible wrapper for discard().
610
+
611
+ This method is kept for backward compatibility; new code should use
612
+ discard(). Behavior is identical to discard().
613
+ """
614
+ return self.discard(key)
615
+
496
616
  def get_subdict(self, prefix_key:PersiDictKey) -> PersiDict:
497
617
  """Get a sub-dictionary containing items with the given prefix key.
498
618
 
499
619
  Items whose keys start with the provided prefix are visible through the
500
620
  returned sub-dictionary. If the prefix does not exist, an empty
501
- sub-dictionary is returned.
621
+ sub-dictionary is returned. If the prefix is empty, the entire
622
+ dictionary is returned.
502
623
 
503
624
  This method is absent in the original Python dict API.
504
625
 
505
626
  Args:
506
627
  prefix_key: Key prefix (string, sequence of strings, or SafeStrTuple)
507
- identifying the sub-namespace to expose.
628
+ identifying the sub-dict to expose.
508
629
 
509
630
  Returns:
510
631
  PersiDict: A dictionary-like view restricted to keys under the
@@ -514,6 +635,7 @@ class PersiDict(MutableMapping, ParameterizableClass):
514
635
  NotImplementedError: Must be implemented by subclasses that support
515
636
  hierarchical key spaces.
516
637
  """
638
+
517
639
  if type(self) is PersiDict:
518
640
  raise NotImplementedError("PersiDict is an abstract base class"
519
641
  " and cannot create sub-dictionaries directly")
@@ -533,7 +655,7 @@ class PersiDict(MutableMapping, ParameterizableClass):
533
655
  return result_subdicts
534
656
 
535
657
 
536
- def random_key(self) -> PersiDictKey | None:
658
+ def random_key(self) -> NonEmptySafeStrTuple | None:
537
659
  """Return a random key from the dictionary.
538
660
 
539
661
  This method is absent in the original Python dict API.
@@ -556,7 +678,7 @@ class PersiDict(MutableMapping, ParameterizableClass):
556
678
  # Reservoir sampling algorithm
557
679
  i = 2
558
680
  for key in iterator:
559
- # Select current key with probability 1/i
681
+ # Select the current key with probability 1/i
560
682
  if random.random() < 1/i:
561
683
  result = key
562
684
  i += 1
@@ -565,7 +687,7 @@ class PersiDict(MutableMapping, ParameterizableClass):
565
687
 
566
688
 
567
689
  @abstractmethod
568
- def timestamp(self, key:PersiDictKey) -> float:
690
+ def timestamp(self, key:NonEmptyPersiDictKey) -> float:
569
691
  """Return the last modification time of a key.
570
692
 
571
693
  This method is absent in the original dict API.
@@ -584,8 +706,19 @@ class PersiDict(MutableMapping, ParameterizableClass):
584
706
  raise NotImplementedError("PersiDict is an abstract base class"
585
707
  " and cannot provide timestamps directly")
586
708
 
709
+ def etag(self, key:NonEmptyPersiDictKey) -> str|None:
710
+ """Return the ETag of a key.
711
+
712
+ By default, this returns a stringified timestamp of the last
713
+ modification time. Subclasses may override to provide true
714
+ backend-specific ETags (e.g., S3).
715
+
716
+ This method is absent in the original Python dict API.
717
+ """
718
+ return f"{self.timestamp(key):.6f}"
719
+
587
720
 
588
- def oldest_keys(self, max_n=None):
721
+ def oldest_keys(self, max_n=None) -> list[NonEmptySafeStrTuple]:
589
722
  """Return up to max_n oldest keys in the dictionary.
590
723
 
591
724
  This method is absent in the original Python dict API.
@@ -613,7 +746,7 @@ class PersiDict(MutableMapping, ParameterizableClass):
613
746
  return [key for key,_ in smallest_pairs]
614
747
 
615
748
 
616
- def oldest_values(self, max_n=None):
749
+ def oldest_values(self, max_n=None) -> list[Any]:
617
750
  """Return up to max_n oldest values in the dictionary.
618
751
 
619
752
  This method is absent in the original Python dict API.
@@ -629,7 +762,7 @@ class PersiDict(MutableMapping, ParameterizableClass):
629
762
  return [self[k] for k in self.oldest_keys(max_n)]
630
763
 
631
764
 
632
- def newest_keys(self, max_n=None):
765
+ def newest_keys(self, max_n=None) -> list[NonEmptySafeStrTuple]:
633
766
  """Return up to max_n newest keys in the dictionary.
634
767
 
635
768
  This method is absent in the original Python dict API.
@@ -657,7 +790,7 @@ class PersiDict(MutableMapping, ParameterizableClass):
657
790
  return [key for key,_ in largest_pairs]
658
791
 
659
792
 
660
- def newest_values(self, max_n=None):
793
+ def newest_values(self, max_n=None) -> list[Any]:
661
794
  """Return up to max_n newest values in the dictionary.
662
795
 
663
796
  This method is absent in the original Python dict API.