python-utils 4.0.1__py3-none-any.whl → 4.1.1__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.
@@ -61,10 +61,13 @@ Examples:
61
61
  import abc
62
62
  import collections
63
63
  import collections.abc
64
+ import contextlib
65
+ import operator
64
66
  import typing
65
67
 
66
68
  if typing.TYPE_CHECKING:
67
69
  import _typeshed # noqa: F401
70
+ import typing_extensions
68
71
 
69
72
  #: A type alias for a type that can be used as a key in a dictionary.
70
73
  KT = typing.TypeVar('KT')
@@ -82,20 +85,65 @@ HT = typing.TypeVar('HT', bound=collections.abc.Hashable)
82
85
  T = typing.TypeVar('T')
83
86
 
84
87
  #: Argument shapes accepted when updating a casted dict: a mapping, an iterable
85
- #: of key/value pairs, an iterable of mappings, or a keys-and-getitem object.
88
+ #: of key/value pairs, or a keys-and-getitem object.
86
89
  # Kept as `typing.Union` (not PEP 604 `|`): one member is a string forward
87
90
  # reference, and `|` evaluates its operands eagerly, raising `TypeError` on a
88
91
  # `str` operand at runtime. `typing.Union` accepts it as a lazy `ForwardRef`.
89
92
  DictUpdateArgs = typing.Union[
90
93
  collections.abc.Mapping[KT, VT],
91
94
  collections.abc.Iterable[tuple[KT, VT]],
92
- collections.abc.Iterable[collections.abc.Mapping[KT, VT]],
93
95
  '_typeshed.SupportsKeysAndGetItem[KT, VT]',
94
96
  ]
95
97
 
96
98
  #: Policy for ``UniqueList`` duplicates: silently ``'ignore'`` or ``'raise'``.
97
99
  OnDuplicate = typing.Literal['ignore', 'raise']
98
100
 
101
+ #: Marks the state that ``CastedDictBase.__reduce_ex__`` writes, to tell it
102
+ #: apart from the default state of an instance with slots.
103
+ _CASTED_DICT_STATE: str = 'python_utils.containers.CastedDictBase'
104
+
105
+
106
+ def _restore_attributes(
107
+ instance: object,
108
+ state: typing.Any,
109
+ inherited: collections.abc.Callable[[typing.Any], None] | None,
110
+ ) -> None:
111
+ """
112
+ Restores the attributes of an instance from its default pickle state.
113
+
114
+ The default state is the instance dictionary, or a tuple of that
115
+ dictionary and the slot values when the class has slots. `pickle` and
116
+ `copy` apply both forms themselves, but only for a class without a
117
+ `__setstate__` of its own.
118
+
119
+ A `__setstate__` that follows the calling class in the method resolution
120
+ order gets the state when there is one. It would have received it if the
121
+ calling class did not define `__setstate__`.
122
+
123
+ Args:
124
+ instance (object): The instance to restore.
125
+ state (typing.Any): The default state of the instance.
126
+ inherited (Callable[[typing.Any], None] | None): The `__setstate__`
127
+ that follows the calling class, or None without one.
128
+ """
129
+ if inherited is not None:
130
+ inherited(state)
131
+ return
132
+
133
+ slots: dict[str, typing.Any] | None = None
134
+ if isinstance(state, tuple):
135
+ state, slots = typing.cast(
136
+ 'tuple[dict[str, typing.Any] | None, dict[str, typing.Any]]',
137
+ state,
138
+ )
139
+
140
+ if state:
141
+ vars(instance).update(state)
142
+
143
+ if slots:
144
+ for name, value in slots.items():
145
+ setattr(instance, name, value)
146
+
99
147
 
100
148
  class CastedDictBase(dict[KT, VT], abc.ABC):
101
149
  """
@@ -119,8 +167,10 @@ class CastedDictBase(dict[KT, VT], abc.ABC):
119
167
  callable is provided.
120
168
  """
121
169
 
122
- _key_cast: KT_cast[KT]
123
- _value_cast: VT_cast[VT]
170
+ # The casts default to `None` on the class so that a dict which `pickle`
171
+ # created without calling `__init__` can already store items.
172
+ _key_cast: KT_cast[KT] = None
173
+ _value_cast: VT_cast[VT] = None
124
174
 
125
175
  def __init__(
126
176
  self,
@@ -160,12 +210,73 @@ class CastedDictBase(dict[KT, VT], abc.ABC):
160
210
  the dictionary.
161
211
  **kwargs (typing.Any): Keyword arguments to update the dictionary.
162
212
  """
163
- if args:
164
- kwargs.update(*args)
213
+ # Keyword arguments are applied last so that they win, as they do
214
+ # for `dict.update`.
215
+ positional: dict[typing.Any, typing.Any] = {}
216
+ positional.update(*args)
217
+ for key, value in positional.items():
218
+ self[key] = value
219
+
220
+ for key, value in kwargs.items():
221
+ self[key] = value
222
+
223
+ def setdefault(self, key: typing.Any, default: typing.Any = None) -> VT:
224
+ """
225
+ Stores the default through the casts when the key is missing.
226
+
227
+ A default of `None` is stored as `None` without casting it, so that
228
+ `setdefault(key)` works for every value cast.
229
+
230
+ Args:
231
+ key (typing.Any): The key to look up, before casting.
232
+ default (typing.Any, optional): The value to store when the key
233
+ is missing. Defaults to None.
234
+
235
+ Returns:
236
+ VT: The value that is stored under the key.
237
+ """
238
+ if self._key_cast is not None:
239
+ key = self._key_cast(key)
240
+
241
+ if not super().__contains__(key):
242
+ stored: typing.Any = default
243
+ if default is not None:
244
+ stored = self._cast_stored_value(default)
245
+
246
+ super().__setitem__(key, stored)
247
+
248
+ return super().__getitem__(key)
249
+
250
+ def _cast_stored_value(self, value: typing.Any) -> typing.Any:
251
+ """
252
+ Casts a value on its way into the dictionary.
253
+
254
+ The base class stores values as they are. A subclass that casts when
255
+ it stores a value overrides this method.
256
+
257
+ Args:
258
+ value (typing.Any): The value to store.
259
+
260
+ Returns:
261
+ typing.Any: The value as it is stored.
262
+ """
263
+ return value
264
+
265
+ def __ior__( # type: ignore[override, misc]
266
+ self, other: DictUpdateArgs[typing.Any, typing.Any]
267
+ ) -> 'typing_extensions.Self':
268
+ """
269
+ Updates the dictionary in place through the casts, see `update`.
165
270
 
166
- if kwargs:
167
- for key, value in kwargs.items():
168
- self[key] = value
271
+ Args:
272
+ other (DictUpdateArgs[typing.Any, typing.Any]): The items to
273
+ merge into the dictionary.
274
+
275
+ Returns:
276
+ typing_extensions.Self: The dictionary itself.
277
+ """
278
+ self.update(other)
279
+ return self
169
280
 
170
281
  def __setitem__(self, key: typing.Any, value: typing.Any) -> None:
171
282
  """
@@ -181,6 +292,81 @@ class CastedDictBase(dict[KT, VT], abc.ABC):
181
292
 
182
293
  return super().__setitem__(key, value)
183
294
 
295
+ def __reduce_ex__(self, protocol: typing.SupportsIndex) -> typing.Any:
296
+ """
297
+ Describes the dictionary for `pickle` and `copy` with its raw items.
298
+
299
+ By default the items are restored through `__setitem__`. `pickle`
300
+ does that before the casts are back and `copy` does it after, which
301
+ casts every value a second time. The items travel inside the state
302
+ to avoid both.
303
+
304
+ The default description is returned unchanged where it already
305
+ works or where other code relies on its form, see the comment below.
306
+
307
+ Args:
308
+ protocol (typing.SupportsIndex): The pickle protocol.
309
+
310
+ Returns:
311
+ typing.Any: The default description, with the items moved into
312
+ the state for protocol 2 and up.
313
+ """
314
+ reduced: typing.Any = super().__reduce_ex__(protocol)
315
+ if (
316
+ operator.index(protocol) < 2
317
+ or len(self) == 0
318
+ or type(self).__reduce__ is not object.__reduce__
319
+ or type(self).__reduce_ex__ is not _BASE_REDUCE_EX
320
+ or type(self).__setstate__ is not _BASE_SETSTATE
321
+ ):
322
+ # Protocol 0 and 1 restore the items through `dict` itself and
323
+ # an empty dictionary has none. A subclass with its own
324
+ # `__reduce__` describes itself. One with its own `__reduce_ex__`
325
+ # or `__setstate__` expects the default state and items.
326
+ return reduced
327
+
328
+ state: tuple[str, typing.Any, dict[KT, VT]] = (
329
+ _CASTED_DICT_STATE,
330
+ reduced[2],
331
+ super().copy(),
332
+ )
333
+ return reduced[0], reduced[1], state, None, None
334
+
335
+ def __setstate__(self, state: object) -> None:
336
+ """
337
+ Restores the attributes and the raw items without casting them.
338
+
339
+ Args:
340
+ state (object): The state from `__reduce_ex__`, or the default
341
+ state for a pickle that already restored its items.
342
+ """
343
+ parts: tuple[object, ...] = ()
344
+ if isinstance(state, tuple):
345
+ parts = typing.cast('tuple[object, ...]', state)
346
+
347
+ # A mixin after this class can have a `__setstate__` of its own.
348
+ inherited: typing.Any = getattr(super(), '__setstate__', None)
349
+ if len(parts) == 3 and parts[0] == _CASTED_DICT_STATE:
350
+ _restore_attributes(self, parts[1], inherited)
351
+ super().update(typing.cast('dict[KT, VT]', parts[2]))
352
+ else:
353
+ _restore_attributes(self, state, inherited)
354
+
355
+
356
+ #: The `__setstate__` that understands the state of
357
+ #: `CastedDictBase.__reduce_ex__`. A subclass that replaces it gets the default
358
+ #: state.
359
+ # Both are taken from the class dictionary. `CastedDictBase[...].__reduce_ex__`
360
+ # would be the method of the generic alias itself.
361
+ _BASE_SETSTATE: collections.abc.Callable[..., None] = vars(CastedDictBase)[
362
+ '__setstate__'
363
+ ]
364
+ #: The `__reduce_ex__` that writes that state. A subclass that replaces it
365
+ #: gets the default description to build on.
366
+ _BASE_REDUCE_EX: collections.abc.Callable[..., typing.Any] = vars(
367
+ CastedDictBase
368
+ )['__reduce_ex__']
369
+
184
370
 
185
371
  class CastedDict(CastedDictBase[KT, VT]):
186
372
  """
@@ -223,10 +409,22 @@ class CastedDict(CastedDictBase[KT, VT]):
223
409
  The key itself is cast by ``CastedDictBase.__setitem__`` when a key
224
410
  cast is configured.
225
411
  """
412
+ super().__setitem__(key, self._cast_stored_value(value))
413
+
414
+ def _cast_stored_value(self, value: typing.Any) -> typing.Any:
415
+ """
416
+ Casts a value on its way into the dictionary.
417
+
418
+ Args:
419
+ value (typing.Any): The value to store.
420
+
421
+ Returns:
422
+ typing.Any: The value, cast when a value cast is set.
423
+ """
226
424
  if self._value_cast is not None:
227
425
  value = self._value_cast(value)
228
426
 
229
- super().__setitem__(key, value)
427
+ return value
230
428
 
231
429
 
232
430
  class LazyCastedDict(CastedDictBase[KT, VT]):
@@ -278,15 +476,14 @@ class LazyCastedDict(CastedDictBase[KT, VT]):
278
476
  def __setitem__(self, key: typing.Any, value: typing.Any) -> None:
279
477
  """
280
478
  Sets the item in the dictionary, casting the key if a key cast
281
- callable is provided.
479
+ callable is provided. The value is stored as it is.
282
480
 
283
481
  Args:
284
482
  key (typing.Any): The key to set in the dictionary.
285
483
  value (typing.Any): The value to set in the dictionary.
286
484
  """
287
- if self._key_cast is not None:
288
- key = self._key_cast(key)
289
-
485
+ # The base class casts the key. Casting it here as well would cast
486
+ # it twice.
290
487
  super().__setitem__(key, value)
291
488
 
292
489
  def __getitem__(self, key: typing.Any) -> VT:
@@ -357,6 +554,16 @@ class UniqueList(list[HT]):
357
554
  >>> l
358
555
  [5, 10, 2, 3, 4]
359
556
 
557
+ A value that was removed or replaced can be added again:
558
+
559
+ >>> l = UniqueList(1, 2, 3)
560
+ >>> l[0] = 4
561
+ >>> l.pop()
562
+ 3
563
+ >>> l.extend([1, 2, 3])
564
+ >>> l
565
+ [4, 2, 1, 3]
566
+
360
567
  >>> l = UniqueList(1, 2, 3, on_duplicate='raise')
361
568
  >>> l.append(4)
362
569
  >>> l.append(4)
@@ -378,6 +585,30 @@ class UniqueList(list[HT]):
378
585
  """
379
586
 
380
587
  _set: set[HT]
588
+ #: The default lives on the class, so that a subclass can set its own
589
+ #: and a list that `pickle` created without `__init__` has one.
590
+ on_duplicate: OnDuplicate = 'ignore'
591
+
592
+ def __new__(
593
+ cls, *args: typing.Any, **kwargs: typing.Any
594
+ ) -> 'typing_extensions.Self':
595
+ """
596
+ Creates the list with an empty membership set.
597
+
598
+ `pickle` and `copy` create the list through `__new__` and refill it
599
+ through `extend` or `append` without calling `__init__`, so the
600
+ membership set has to exist before `__init__` runs.
601
+
602
+ Args:
603
+ *args (typing.Any): Ignored, handled by `__init__`.
604
+ **kwargs (typing.Any): Ignored, handled by `__init__`.
605
+
606
+ Returns:
607
+ typing_extensions.Self: The new, empty list.
608
+ """
609
+ instance: typing_extensions.Self = super().__new__(cls)
610
+ instance._set = set()
611
+ return instance
381
612
 
382
613
  def __init__(
383
614
  self,
@@ -398,6 +629,44 @@ class UniqueList(list[HT]):
398
629
  for arg in args:
399
630
  self.append(arg)
400
631
 
632
+ def __setstate__(self, state: typing.Any) -> None:
633
+ """
634
+ Restores the attributes and rebuilds the membership from the items.
635
+
636
+ `copy` restores the attributes before the items and `pickle` restores
637
+ them after the items. A stored membership set is only right in the
638
+ second case, so the set is always derived from the items that are in
639
+ the list at this point.
640
+
641
+ Args:
642
+ state (typing.Any): The instance attributes, with the slot values
643
+ when a subclass has slots.
644
+ """
645
+ # A mixin after this class can have a `__setstate__` of its own.
646
+ inherited: typing.Any = getattr(super(), '__setstate__', None)
647
+ _restore_attributes(self, state, inherited)
648
+ self._set = set(self)
649
+
650
+ def _release(self, value: HT) -> None:
651
+ """
652
+ Drops a value that left the list from the membership set.
653
+
654
+ The set is rebuilt from the list when the value cannot be removed
655
+ from it. That happens when the value changed its hash after it was
656
+ added, and when its `__hash__` or `__eq__` raises.
657
+
658
+ The value has left the list by now, so nothing here may raise. When
659
+ the rebuild fails as well the set stays as it is.
660
+
661
+ Args:
662
+ value (HT): The value that was removed from the list.
663
+ """
664
+ try:
665
+ self._set.remove(value)
666
+ except Exception: # noqa: BLE001
667
+ with contextlib.suppress(Exception):
668
+ self._set = set(self)
669
+
401
670
  def insert(self, index: typing.SupportsIndex, value: HT) -> None:
402
671
  """
403
672
  Inserts a value at the specified index, ensuring uniqueness.
@@ -416,8 +685,15 @@ class UniqueList(list[HT]):
416
685
  else:
417
686
  return
418
687
 
688
+ # The membership first, right after the check above. Another thread
689
+ # that inserts the same value in between would get past its own
690
+ # check as well. A failed insert takes the membership back.
419
691
  self._set.add(value)
420
- super().insert(index, value)
692
+ try:
693
+ super().insert(index, value)
694
+ except BaseException:
695
+ self._set.discard(value)
696
+ raise
421
697
 
422
698
  def append(self, value: HT) -> None:
423
699
  """
@@ -439,6 +715,133 @@ class UniqueList(list[HT]):
439
715
  self._set.add(value)
440
716
  super().append(value)
441
717
 
718
+ def extend(self, values: collections.abc.Iterable[HT]) -> None:
719
+ """
720
+ Extends the list with the values that are not in it yet.
721
+
722
+ Args:
723
+ values (Iterable[HT]): The values to append.
724
+
725
+ Raises:
726
+ ValueError: If `on_duplicate` is set to 'raise' and a value is
727
+ already in the list or occurs more than once in `values`.
728
+ The list is left unchanged in that case.
729
+ """
730
+ new_values: list[HT] = list(values)
731
+ if self.on_duplicate == 'raise':
732
+ duplicates: set[HT] = self._find_duplicates(new_values)
733
+ if duplicates:
734
+ raise ValueError(f'Duplicate values: {duplicates}')
735
+
736
+ for value in new_values:
737
+ self.append(value)
738
+
739
+ # `list.__iadd__` accepts any iterable while `list.__add__` only accepts
740
+ # a list. Typeshed ignores the same mismatch.
741
+ def __iadd__( # type: ignore[misc, override]
742
+ self, values: collections.abc.Iterable[HT]
743
+ ) -> 'typing_extensions.Self':
744
+ """
745
+ Extends the list in place, see `extend`.
746
+
747
+ Args:
748
+ values (Iterable[HT]): The values to append.
749
+
750
+ Returns:
751
+ typing_extensions.Self: The list itself.
752
+ """
753
+ self.extend(values)
754
+ return self
755
+
756
+ def __imul__(
757
+ self, value: typing.SupportsIndex
758
+ ) -> 'typing_extensions.Self':
759
+ """
760
+ Multiplies the list in place without ever repeating an item.
761
+
762
+ A count below 1 empties the list, as it does for a regular list. A
763
+ count above 1 would repeat every item, so the repeat goes through
764
+ `extend` and follows `on_duplicate`.
765
+
766
+ Args:
767
+ value (typing.SupportsIndex): The number of times to repeat.
768
+
769
+ Returns:
770
+ typing_extensions.Self: The list itself.
771
+
772
+ Raises:
773
+ ValueError: If `on_duplicate` is set to 'raise' and a non-empty
774
+ list is multiplied by more than 1.
775
+ """
776
+ count: int = operator.index(value)
777
+ if count < 1:
778
+ self.clear()
779
+ elif count > 1:
780
+ self.extend(self)
781
+
782
+ return self
783
+
784
+ def pop(self, index: typing.SupportsIndex = -1) -> HT:
785
+ """
786
+ Removes and returns the item at the given index.
787
+
788
+ Args:
789
+ index (typing.SupportsIndex, optional): The index to pop.
790
+ Defaults to the last item.
791
+
792
+ Returns:
793
+ HT: The removed item.
794
+ """
795
+ value: HT = super().pop(index)
796
+ self._release(value)
797
+ return value
798
+
799
+ def remove(self, value: HT) -> None:
800
+ """
801
+ Removes a value from the list.
802
+
803
+ Args:
804
+ value (HT): The value to remove.
805
+
806
+ Raises:
807
+ ValueError: If the value is not in the list.
808
+ """
809
+ # One list operation, so that another thread cannot get between
810
+ # finding the value and removing it.
811
+ super().remove(value)
812
+ self._release(value)
813
+
814
+ def clear(self) -> None:
815
+ """Removes all items from the list."""
816
+ super().clear()
817
+ self._set.clear()
818
+
819
+ def _find_duplicates(
820
+ self,
821
+ values: list[HT],
822
+ replaced: collections.abc.Set[HT] = frozenset(),
823
+ ) -> set[HT]:
824
+ """
825
+ Finds the values that would break uniqueness when added.
826
+
827
+ Args:
828
+ values (list[HT]): The values to add.
829
+ replaced (Set[HT], optional): Items that leave the list in the
830
+ same operation, so that adding them again is allowed.
831
+
832
+ Returns:
833
+ set[HT]: The values that occur more than once in `values` or
834
+ that are already in the list and not in `replaced`.
835
+ """
836
+ seen: set[HT] = set()
837
+ duplicates: set[HT] = set()
838
+ for value in values:
839
+ if value in seen or (value in self._set and value not in replaced):
840
+ duplicates.add(value)
841
+ seen.add(value)
842
+
843
+ return duplicates
844
+
442
845
  def __contains__(self, item: HT) -> bool: # type: ignore[override]
443
846
  """
444
847
  Checks if the list contains the specified item.
@@ -449,7 +852,12 @@ class UniqueList(list[HT]):
449
852
  Returns:
450
853
  bool: True if the item is in the list, False otherwise.
451
854
  """
452
- return item in self._set
855
+ try:
856
+ return item in self._set
857
+ except TypeError:
858
+ # An unhashable item cannot be in the set, but it can still be
859
+ # equal to an item in the list.
860
+ return super().__contains__(item)
453
861
 
454
862
  @typing.overload
455
863
  def __setitem__(self, indices: typing.SupportsIndex, values: HT) -> None:
@@ -480,31 +888,93 @@ class UniqueList(list[HT]):
480
888
  set to 'raise'.
481
889
  """
482
890
  if isinstance(indices, slice):
483
- values = typing.cast(collections.abc.Iterable[HT], values)
484
- if self.on_duplicate == 'ignore':
485
- raise RuntimeError(
486
- 'ignore mode while setting slices introduces ambiguous '
487
- 'behaviour and is therefore not supported'
488
- )
489
-
490
- duplicates: set[HT] = set(values) & self._set
491
- if duplicates and values != list(self[indices]):
492
- raise ValueError(f'Duplicate values: {duplicates}')
493
-
494
- self._set.update(values)
891
+ self._set_slice(
892
+ indices, typing.cast(collections.abc.Iterable[HT], values)
893
+ )
495
894
  else:
496
- values = typing.cast(HT, values)
497
- if values in self._set and values != self[indices]:
498
- if self.on_duplicate == 'raise':
499
- raise ValueError(f'Duplicate value: {values}')
500
- else:
501
- return
895
+ self._set_index(indices, typing.cast(HT, values))
896
+
897
+ def _set_slice(
898
+ self, indices: slice, values: collections.abc.Iterable[HT]
899
+ ) -> None:
900
+ """
901
+ Replaces a slice of the list and keeps the membership in sync.
902
+
903
+ The new values can reuse the items they replace. They cannot repeat
904
+ each other or an item that stays in the list.
502
905
 
503
- self._set.add(values)
906
+ Args:
907
+ indices (slice): The slice to replace.
908
+ values (Iterable[HT]): The values to store.
909
+
910
+ Raises:
911
+ RuntimeError: If `on_duplicate` is 'ignore'.
912
+ ValueError: If storing the values would create a duplicate.
913
+ """
914
+ if self.on_duplicate == 'ignore':
915
+ raise RuntimeError(
916
+ 'ignore mode while setting slices introduces ambiguous '
917
+ 'behaviour and is therefore not supported'
918
+ )
504
919
 
505
- super().__setitem__(
506
- typing.cast(slice, indices), typing.cast(list[HT], values)
920
+ new_values: list[HT] = list(values)
921
+ old_values: list[HT] = self[indices]
922
+ duplicates: set[HT] = self._find_duplicates(
923
+ new_values, replaced=set(old_values)
507
924
  )
925
+ if duplicates:
926
+ raise ValueError(f'Duplicate values: {duplicates}')
927
+
928
+ # The membership first, for the same reason as in `insert`.
929
+ fresh: set[HT] = set(new_values).difference(self._set)
930
+ self._set.update(fresh)
931
+ try:
932
+ super().__setitem__(indices, new_values)
933
+ except BaseException:
934
+ self._set.difference_update(fresh)
935
+ raise
936
+
937
+ # Only what really left the list. A value that the slice reuses
938
+ # stays a member the whole time.
939
+ self._set.difference_update(set(old_values).difference(new_values))
940
+
941
+ def _set_index(self, index: typing.SupportsIndex, value: HT) -> None:
942
+ """
943
+ Replaces a single item and keeps the membership in sync.
944
+
945
+ Args:
946
+ index (typing.SupportsIndex): The index to replace.
947
+ value (HT): The value to store.
948
+
949
+ Raises:
950
+ ValueError: If the value is a duplicate of another item and
951
+ `on_duplicate` is set to 'raise'.
952
+ """
953
+ old_value: HT = self[index]
954
+ known: bool = value in self._set
955
+ if known and value != old_value:
956
+ if self.on_duplicate == 'raise':
957
+ raise ValueError(f'Duplicate value: {value}')
958
+ else:
959
+ return
960
+
961
+ # The membership first, for the same reason as in `insert`.
962
+ self._set.add(value)
963
+ try:
964
+ super().__setitem__(index, value)
965
+ except BaseException:
966
+ if not known:
967
+ self._set.discard(value)
968
+
969
+ raise
970
+
971
+ if not known:
972
+ # A known value replaces itself and stays a member the whole
973
+ # time. For a new value the old one leaves.
974
+ self._release(old_value)
975
+ # The release dropped the new value as well when it is the old
976
+ # object with a changed hash.
977
+ self._set.add(value)
508
978
 
509
979
  def __delitem__(self, index: typing.SupportsIndex | slice) -> None:
510
980
  """
@@ -514,13 +984,16 @@ class UniqueList(list[HT]):
514
984
  index (typing.SupportsIndex | slice): The index or slice
515
985
  to delete the item(s) at.
516
986
  """
987
+ removed: list[HT]
517
988
  if isinstance(index, slice):
518
- for value in self[index]:
519
- self._set.remove(value)
989
+ removed = self[index]
520
990
  else:
521
- self._set.remove(self[index])
991
+ removed = [self[index]]
522
992
 
993
+ # The list first, then the membership, like every other mutator.
523
994
  super().__delitem__(index)
995
+ for value in removed:
996
+ self._release(value)
524
997
 
525
998
 
526
999
  # Type hinting `collections.deque` does not work consistently between Python
@@ -595,10 +1068,33 @@ class SliceableDeque(typing.Generic[T], collections.deque[T]):
595
1068
  elif isinstance(other, tuple):
596
1069
  return tuple(self) == other
597
1070
  elif isinstance(other, set):
598
- return set(self) == other
1071
+ try:
1072
+ return set(self) == other
1073
+ except TypeError:
1074
+ # An unhashable item cannot be in a set.
1075
+ return False
599
1076
  else:
600
1077
  return super().__eq__(other)
601
1078
 
1079
+ def __ne__(self, other: typing.Any) -> bool:
1080
+ """
1081
+ Checks inequality as the opposite of `__eq__`.
1082
+
1083
+ `collections.deque` has its own `__ne__`, which does not know about
1084
+ the lists, tuples and sets that `__eq__` accepts.
1085
+
1086
+ Args:
1087
+ other (typing.Any): The object to compare with.
1088
+
1089
+ Returns:
1090
+ bool: False if the objects are equal, True otherwise.
1091
+ """
1092
+ equal: typing.Any = self.__eq__(other)
1093
+ if equal is NotImplemented:
1094
+ return NotImplemented
1095
+
1096
+ return not equal
1097
+
602
1098
  def pop(self, index: int = -1) -> T:
603
1099
  """
604
1100
  Removes and returns the item at the given index. Only supports index 0