linkedset 0.1.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.
linkedset/__init__.py ADDED
@@ -0,0 +1,606 @@
1
+ # Copyright (c) ONNX Project Contributors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ # Modifications Copyright (c) Justin Chu
4
+ # SPDX-License-Identifier: MIT
5
+ """Mutable, ordered set with safe mutation properties."""
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Iterable, Iterator, MutableSet, Sequence
10
+ from typing import Generic, TypeVar, overload
11
+
12
+ __all__ = ["DoublyLinkedSet"]
13
+
14
+ _T = TypeVar("_T")
15
+
16
+
17
+ class _LinkBox(Generic[_T]):
18
+ """A link in a doubly linked list that has a reference to the actual object in the link.
19
+
20
+ The :class:`_LinkBox` is a container for the actual object in the list. It is used to
21
+ maintain the links between the elements in the linked list. The actual object is stored in the
22
+ :attr:`value` attribute.
23
+
24
+ By using a separate container for the actual object, we can safely remove the object from the
25
+ list without losing the links. This allows us to remove the object from the list during
26
+ iteration and place the object into a different list without breaking any chains.
27
+
28
+ This is an internal class and should only be initialized by the :class:`DoublyLinkedSet`.
29
+
30
+ Attributes:
31
+ prev: The previous box in the list.
32
+ next: The next box in the list.
33
+ erased: A flag to indicate if the box has been removed from the list.
34
+ owning_list: The :class:`DoublyLinkedSet` to which the box belongs.
35
+ value: The actual object in the list.
36
+ """
37
+
38
+ __slots__ = ("next", "owning_list", "prev", "value")
39
+
40
+ def __init__(self, owner: DoublyLinkedSet[_T], value: _T | None) -> None:
41
+ """Create a new link box.
42
+
43
+ Args:
44
+ owner: The linked list to which this box belongs.
45
+ value: The value to be stored in the link box. When the value is None,
46
+ the link box is considered erased (default). The root box of the list
47
+ should be created with a None value.
48
+ """
49
+ self.prev: _LinkBox[_T] = self
50
+ self.next: _LinkBox[_T] = self
51
+ self.value: _T | None = value
52
+ self.owning_list: DoublyLinkedSet[_T] = owner
53
+
54
+ @property
55
+ def erased(self) -> bool:
56
+ return self.value is None
57
+
58
+ def erase(self) -> None:
59
+ """Remove the link from the list and detach the value from the box."""
60
+ if self.value is None:
61
+ raise ValueError("_LinkBox is already erased")
62
+ # Update the links
63
+ prev, next_ = self.prev, self.next
64
+ prev.next, next_.prev = next_, prev
65
+ # Detach the value
66
+ self.value = None
67
+
68
+ def __repr__(self) -> str:
69
+ return (
70
+ f"_LinkBox({self.value!r}, erased={self.erased}, "
71
+ f"prev={self.prev.value!r}, next={self.next.value!r})"
72
+ )
73
+
74
+
75
+ class DoublyLinkedSet(Sequence[_T], MutableSet[_T], Generic[_T]):
76
+ """A doubly linked ordered set of nodes.
77
+
78
+ The container is both a :class:`collections.abc.Sequence` (ordered, indexable) and a
79
+ :class:`collections.abc.MutableSet`. It does not allow duplicate values and maintains
80
+ insertion order. One can treat it as a doubly linked list with list-like methods, or as
81
+ a mutable set supporting ``&``, ``|``, ``-``, ``^`` and their in-place variants.
82
+
83
+ Adding and removing elements from the set during iteration is safe. Moving elements
84
+ from one set to another is also safe.
85
+
86
+ During the iteration:
87
+
88
+ - If new elements are inserted after the current node, the iterator will
89
+ iterate over them as well.
90
+ - If new elements are inserted before the current node, they will
91
+ not be iterated over in this iteration.
92
+ - If the current node is lifted and inserted in a different location,
93
+ iteration will start from the "next" node at the _original_ location.
94
+
95
+ Time complexity:
96
+ Inserting and removing nodes from the set is O(1). Accessing nodes by index is O(n),
97
+ although accessing nodes at either end of the set is O(1). I.e.
98
+ ``linked_set[0]`` and ``linked_set[-1]`` are O(1).
99
+
100
+ Membership, uniqueness and set operations are based on object **identity** (``id(value)``),
101
+ not equality. Two distinct objects that compare equal are treated as different elements.
102
+ Because it is a mutable set, instances are not hashable. Since the set is *ordered*, ``==``
103
+ is order-sensitive: two sets are equal only when they hold the same elements (by identity)
104
+ in the same order. ``None`` is not a valid value in the set.
105
+ """
106
+
107
+ __slots__ = ("_length", "_root", "_value_ids_to_boxes")
108
+
109
+ def __init__(self, values: Iterable[_T] | None = None) -> None:
110
+ # Using the root node simplifies the mutation implementation a lot
111
+ # The list is circular. The root node is the only node that is not a part of the list values
112
+ root_ = _LinkBox(self, None)
113
+ self._root: _LinkBox = root_
114
+ self._length = 0
115
+ self._value_ids_to_boxes: dict[int, _LinkBox] = {}
116
+ if values is not None:
117
+ self.extend(values)
118
+
119
+ def __iter__(self) -> Iterator[_T]:
120
+ """Iterate over the elements in the list.
121
+
122
+ - If new elements are inserted after the current node, the iterator will
123
+ iterate over them as well.
124
+ - If new elements are inserted before the current node, they will
125
+ not be iterated over in this iteration.
126
+ - If the current node is lifted and inserted in a different location,
127
+ iteration will start from the "next" node at the _original_ location.
128
+ """
129
+ box = self._root.next
130
+ while box is not self._root:
131
+ if box.owning_list is not self:
132
+ raise RuntimeError(f"Element {box!r} is not in the list")
133
+ if not box.erased:
134
+ assert box.value is not None
135
+ yield box.value
136
+ box = box.next
137
+
138
+ def __reversed__(self) -> Iterator[_T]:
139
+ """Iterate over the elements in the list in reverse order."""
140
+ box = self._root.prev
141
+ while box is not self._root:
142
+ if not box.erased:
143
+ assert box.value is not None
144
+ yield box.value
145
+ box = box.prev
146
+
147
+ def __len__(self) -> int:
148
+ assert self._length == len(self._value_ids_to_boxes), (
149
+ "Bug in the implementation: length mismatch"
150
+ )
151
+ return self._length
152
+
153
+ def __contains__(self, value: object) -> bool:
154
+ """Return whether ``value`` is in the set, using object identity."""
155
+ return id(value) in self._value_ids_to_boxes
156
+
157
+ def __eq__(self, other: object) -> bool:
158
+ """Return whether ``other`` is an equal, equally-ordered set.
159
+
160
+ Because :class:`DoublyLinkedSet` is *ordered*, equality is order-sensitive
161
+ (unlike a plain :class:`set`). Two sets are equal only when they contain the same
162
+ elements, by object identity, in the same order. Only another
163
+ :class:`DoublyLinkedSet` can compare equal.
164
+ """
165
+ if self is other:
166
+ return True
167
+ if not isinstance(other, DoublyLinkedSet):
168
+ return NotImplemented
169
+ if self._length != other._length:
170
+ return False
171
+ return all(a is b for a, b in zip(self, other))
172
+
173
+ __hash__ = None # type: ignore[assignment] # ordered, mutable -> unhashable
174
+
175
+ def count(self, value: object) -> int:
176
+ """Return the number of occurrences of ``value`` (0 or 1), using object identity."""
177
+ return 1 if id(value) in self._value_ids_to_boxes else 0
178
+
179
+ def index(self, value: object, start: int = 0, stop: int | None = None) -> int:
180
+ """Return the index of ``value`` in the set, using object identity.
181
+
182
+ Raises:
183
+ ValueError: If ``value`` is not present in ``self[start:stop]``.
184
+ """
185
+ length = self._length
186
+ if start < 0:
187
+ start = max(length + start, 0)
188
+ if stop is None:
189
+ stop = length
190
+ elif stop < 0:
191
+ stop += length
192
+ for i, item in enumerate(self):
193
+ if i >= stop:
194
+ break
195
+ if i >= start and item is value:
196
+ return i
197
+ raise ValueError(f"{value!r} is not in the set")
198
+
199
+ @overload
200
+ def __getitem__(self, index: int) -> _T: ...
201
+ @overload
202
+ def __getitem__(self, index: slice) -> Sequence[_T]: ...
203
+
204
+ def __getitem__(self, index):
205
+ """Get the node at the given index.
206
+
207
+ Complexity is O(n).
208
+ """
209
+ if isinstance(index, slice):
210
+ return tuple(self)[index]
211
+ if index >= self._length or index < -self._length:
212
+ raise IndexError(
213
+ f"Index out of range: {index} not in range [-{self._length}, {self._length})"
214
+ )
215
+ if index < 0:
216
+ # Look up from the end of the list
217
+ iterator = reversed(self)
218
+ item = next(iterator)
219
+ for _ in range(-index - 1):
220
+ item = next(iterator)
221
+ else:
222
+ iterator = iter(self) # type: ignore[assignment]
223
+ item = next(iterator)
224
+ for _ in range(index):
225
+ item = next(iterator)
226
+ return item
227
+
228
+ def _insert_one_after(
229
+ self,
230
+ box: _LinkBox[_T],
231
+ new_value: _T,
232
+ ) -> _LinkBox[_T]:
233
+ """Insert a new value after the given box.
234
+
235
+ All insertion methods should call this method to ensure that the list is updated correctly.
236
+
237
+ Example::
238
+ Before: A <-> B <-> C
239
+ ^v0 ^v1 ^v2
240
+ Call: _insert_one_after(B, v3)
241
+ After: A <-> B <-> new_box <-> C
242
+ ^v0 ^v1 ^v3 ^v2
243
+
244
+ Args:
245
+ box: The box which the new value is to be inserted.
246
+ new_value: The new value to be inserted.
247
+ """
248
+ if new_value is None:
249
+ raise TypeError(f"{self.__class__.__name__} does not support None values")
250
+ if box.value is new_value:
251
+ # Do nothing if the new value is the same as the old value
252
+ return box
253
+ if box.owning_list is not self:
254
+ raise ValueError(f"Value {box.value!r} is not in the list")
255
+
256
+ if (new_value_id := id(new_value)) in self._value_ids_to_boxes:
257
+ # If the value is already in the list, remove it first
258
+ self.remove(new_value)
259
+
260
+ # Create a new _LinkBox for the new value
261
+ new_box = _LinkBox(self, new_value)
262
+ # original_box <=> original_next
263
+ # becomes
264
+ # original_box <=> new_box <=> original_next
265
+ original_next = box.next
266
+ box.next = new_box
267
+ new_box.prev = box
268
+ new_box.next = original_next
269
+ original_next.prev = new_box
270
+
271
+ # Be sure to update the length and mapping
272
+ self._length += 1
273
+ self._value_ids_to_boxes[new_value_id] = new_box
274
+
275
+ return new_box
276
+
277
+ def _insert_many_after(
278
+ self,
279
+ box: _LinkBox[_T],
280
+ new_values: Iterable[_T],
281
+ ):
282
+ """Insert multiple new values after the given box."""
283
+ insertion_point = box
284
+ for new_value in new_values:
285
+ insertion_point = self._insert_one_after(insertion_point, new_value)
286
+
287
+ def remove(self, value: _T) -> None:
288
+ """Remove a node from the list."""
289
+ if (value_id := id(value)) not in self._value_ids_to_boxes:
290
+ raise ValueError(f"Value {value!r} is not in the list")
291
+ box = self._value_ids_to_boxes[value_id]
292
+ # Remove the link box and detach the value from the box
293
+ box.erase()
294
+
295
+ # Be sure to update the length and mapping
296
+ self._length -= 1
297
+ del self._value_ids_to_boxes[value_id]
298
+
299
+ def add(self, value: _T) -> None:
300
+ """Add ``value`` to the end of the set if it is not already present.
301
+
302
+ Unlike :meth:`append`, this is idempotent: if ``value`` (by identity) is already
303
+ in the set, its position is left unchanged. This implements
304
+ :meth:`collections.abc.MutableSet.add`.
305
+ """
306
+ if id(value) not in self._value_ids_to_boxes:
307
+ self.append(value)
308
+
309
+ def discard(self, value: _T) -> None:
310
+ """Remove ``value`` from the set if it is present, without raising otherwise.
311
+
312
+ This implements :meth:`collections.abc.MutableSet.discard`.
313
+ """
314
+ if id(value) in self._value_ids_to_boxes:
315
+ self.remove(value)
316
+
317
+ def append(self, value: _T) -> None:
318
+ """Append a node to the list."""
319
+ _ = self._insert_one_after(self._root.prev, value)
320
+
321
+ def extend(
322
+ self,
323
+ values: Iterable[_T],
324
+ ) -> None:
325
+ for value in values:
326
+ self.append(value)
327
+
328
+ def appendleft(self, value: _T) -> None:
329
+ """Add ``value`` to the front of the set (deque-style).
330
+
331
+ If ``value`` is already present (by identity), it is moved to the front.
332
+ """
333
+ _ = self._insert_one_after(self._root, value)
334
+
335
+ def extendleft(self, values: Iterable[_T]) -> None:
336
+ """Prepend each value in ``values`` to the front of the set (deque-style).
337
+
338
+ As with :meth:`collections.deque.extendleft`, the prepended items end up in reverse
339
+ order relative to ``values``.
340
+ """
341
+ for value in values:
342
+ self.appendleft(value)
343
+
344
+ def _box_at(self, index: int) -> _LinkBox[_T]:
345
+ """Return the live link box at ``index`` in ``[0, len)``, walking from the nearer end."""
346
+ if index <= self._length - 1 - index:
347
+ box = self._root.next
348
+ for _ in range(index):
349
+ box = box.next
350
+ else:
351
+ box = self._root.prev
352
+ for _ in range(self._length - 1 - index):
353
+ box = box.prev
354
+ return box
355
+
356
+ def insert(self, index: int, value: _T) -> None:
357
+ """Insert ``value`` before position ``index`` (list/deque-style).
358
+
359
+ ``index`` is clamped like :meth:`list.insert`. If ``value`` is already present it is
360
+ removed first, so ``index`` refers to a position in the resulting sequence.
361
+ """
362
+ if value is None:
363
+ raise TypeError(f"{self.__class__.__name__} does not support None values")
364
+ if id(value) in self._value_ids_to_boxes:
365
+ self.remove(value)
366
+ length = self._length
367
+ if index < 0:
368
+ index = max(length + index, 0)
369
+ if index >= length:
370
+ self.append(value)
371
+ return
372
+ target = self._box_at(index)
373
+ self._insert_one_after(target.prev, value)
374
+
375
+ def pop(self, index: int = -1) -> _T:
376
+ """Remove and return the value at ``index`` (default: the last one, list-style).
377
+
378
+ Raises:
379
+ IndexError: If the set is empty or ``index`` is out of range.
380
+ """
381
+ if self._length == 0:
382
+ raise IndexError("pop from empty DoublyLinkedSet")
383
+ value = self[index]
384
+ self.remove(value)
385
+ return value
386
+
387
+ def popleft(self) -> _T:
388
+ """Remove and return the first value (deque-style).
389
+
390
+ Raises:
391
+ IndexError: If the set is empty.
392
+ """
393
+ if self._length == 0:
394
+ raise IndexError("popleft from empty DoublyLinkedSet")
395
+ value = self[0]
396
+ self.remove(value)
397
+ return value
398
+
399
+ def clear(self) -> None:
400
+ """Remove all elements from the set.
401
+
402
+ Safe to call during iteration: existing nodes are marked erased (so a live iterator
403
+ stops yielding them) before the set is reset.
404
+ """
405
+ box = self._root.next
406
+ while box is not self._root:
407
+ # Detach the value but keep next/prev intact so a live iterator can still advance.
408
+ nxt = box.next
409
+ box.value = None
410
+ box = nxt
411
+ self._root.prev = self._root
412
+ self._root.next = self._root
413
+ self._length = 0
414
+ self._value_ids_to_boxes.clear()
415
+
416
+ def reverse(self) -> None:
417
+ """Reverse the elements of the set in place.
418
+
419
+ Note:
420
+ Unlike per-element mutation, this is a global reorder and is **not** safe to call
421
+ while iterating over the set: an in-progress iterator may then skip or repeat
422
+ elements.
423
+ """
424
+ node = self._root.next
425
+ while node is not self._root:
426
+ nxt = node.next
427
+ node.next, node.prev = node.prev, node.next
428
+ node = nxt
429
+ self._root.next, self._root.prev = self._root.prev, self._root.next
430
+
431
+ def rotate(self, n: int = 1) -> None:
432
+ """Rotate the set ``n`` steps to the right (deque-style).
433
+
434
+ Negative ``n`` rotates to the left. ``rotate(1)`` moves the last element to the front.
435
+
436
+ Note:
437
+ Like :meth:`reverse`, this is a global reorder and is **not** safe to call while
438
+ iterating over the set.
439
+ """
440
+ length = self._length
441
+ if length <= 1:
442
+ return
443
+ n %= length
444
+ if n == 0:
445
+ return
446
+ # The new first element is the n-th element counted from the end.
447
+ new_head = self._root.prev
448
+ for _ in range(n - 1):
449
+ new_head = new_head.prev
450
+ new_tail = new_head.prev
451
+ old_head = self._root.next
452
+ old_tail = self._root.prev
453
+ # Close the value ring, removing the root sentinel from between old_tail and old_head.
454
+ old_tail.next = old_head
455
+ old_head.prev = old_tail
456
+ # Splice the root sentinel back in between new_tail and new_head.
457
+ new_tail.next = self._root
458
+ self._root.prev = new_tail
459
+ self._root.next = new_head
460
+ new_head.prev = self._root
461
+
462
+ def copy(self) -> DoublyLinkedSet[_T]:
463
+ """Return a shallow copy of the set, preserving order."""
464
+ return DoublyLinkedSet(self)
465
+
466
+ # -- Set algebra -------------------------------------------------------------------------
467
+ # These override the collections.abc.Set mixins so that they use identity (``id``)
468
+ # semantics consistently and preserve this set's (left-operand) order, even when the other
469
+ # operand is an equality-based container such as a built-in ``set``.
470
+
471
+ @staticmethod
472
+ def _ids_of(iterable: Iterable[object]) -> set[int]:
473
+ return {id(value) for value in iterable}
474
+
475
+ def __and__(self, other: Iterable[_T]) -> DoublyLinkedSet[_T]:
476
+ if not isinstance(other, Iterable):
477
+ return NotImplemented
478
+ other_ids = self._ids_of(other)
479
+ result: DoublyLinkedSet[_T] = DoublyLinkedSet()
480
+ for value in self:
481
+ if id(value) in other_ids:
482
+ result.append(value)
483
+ return result
484
+
485
+ def __rand__(self, other: Iterable[_T]) -> DoublyLinkedSet[_T]:
486
+ # Reflected: ``other`` is the left operand, so honour its order.
487
+ if not isinstance(other, Iterable):
488
+ return NotImplemented
489
+ result: DoublyLinkedSet[_T] = DoublyLinkedSet()
490
+ for value in other:
491
+ if id(value) in self._value_ids_to_boxes:
492
+ result.append(value)
493
+ return result
494
+
495
+ def __or__(self, other: Iterable[_T]) -> DoublyLinkedSet[_T]:
496
+ if not isinstance(other, Iterable):
497
+ return NotImplemented
498
+ result = self.copy()
499
+ for value in other:
500
+ result.add(value)
501
+ return result
502
+
503
+ def __ror__(self, other: Iterable[_T]) -> DoublyLinkedSet[_T]:
504
+ if not isinstance(other, Iterable):
505
+ return NotImplemented
506
+ result: DoublyLinkedSet[_T] = DoublyLinkedSet(other)
507
+ for value in self:
508
+ result.add(value)
509
+ return result
510
+
511
+ def __sub__(self, other: Iterable[_T]) -> DoublyLinkedSet[_T]:
512
+ if not isinstance(other, Iterable):
513
+ return NotImplemented
514
+ other_ids = self._ids_of(other)
515
+ result: DoublyLinkedSet[_T] = DoublyLinkedSet()
516
+ for value in self:
517
+ if id(value) not in other_ids:
518
+ result.append(value)
519
+ return result
520
+
521
+ def __rsub__(self, other: Iterable[_T]) -> DoublyLinkedSet[_T]:
522
+ if not isinstance(other, Iterable):
523
+ return NotImplemented
524
+ result: DoublyLinkedSet[_T] = DoublyLinkedSet()
525
+ for value in other:
526
+ if id(value) not in self._value_ids_to_boxes:
527
+ result.append(value)
528
+ return result
529
+
530
+ def __xor__(self, other: Iterable[_T]) -> DoublyLinkedSet[_T]:
531
+ if not isinstance(other, Iterable):
532
+ return NotImplemented
533
+ other_values = list(other)
534
+ other_ids = self._ids_of(other_values)
535
+ result: DoublyLinkedSet[_T] = DoublyLinkedSet()
536
+ for value in self:
537
+ if id(value) not in other_ids:
538
+ result.append(value)
539
+ for value in other_values:
540
+ if id(value) not in self._value_ids_to_boxes:
541
+ result.add(value)
542
+ return result
543
+
544
+ def __rxor__(self, other: Iterable[_T]) -> DoublyLinkedSet[_T]:
545
+ # Reflected: ``other`` is the left operand, so emit its unique values first, in order.
546
+ if not isinstance(other, Iterable):
547
+ return NotImplemented
548
+ other_values = list(other)
549
+ other_ids = self._ids_of(other_values)
550
+ result: DoublyLinkedSet[_T] = DoublyLinkedSet()
551
+ for value in other_values:
552
+ if id(value) not in self._value_ids_to_boxes:
553
+ result.append(value)
554
+ for value in self:
555
+ if id(value) not in other_ids:
556
+ result.add(value)
557
+ return result
558
+
559
+ def isdisjoint(self, other: Iterable[_T]) -> bool:
560
+ """Return ``True`` if the set has no elements (by identity) in common with ``other``."""
561
+ return not any(id(value) in self._value_ids_to_boxes for value in other)
562
+
563
+ def _unsupported_ordering(self, _other: object) -> bool:
564
+ raise TypeError(
565
+ f"{type(self).__name__} does not support ordering/subset comparisons "
566
+ "(<, <=, >, >=) because '==' is order-sensitive; "
567
+ "use set algebra (&, |, -, ^) or isdisjoint() instead"
568
+ )
569
+
570
+ # Subset/superset ordering would clash with the order-sensitive ``==``, so it is disabled.
571
+ __lt__ = __le__ = __gt__ = __ge__ = _unsupported_ordering
572
+
573
+ def insert_after(
574
+ self,
575
+ value: _T,
576
+ new_values: Iterable[_T],
577
+ ) -> None:
578
+ """Insert new nodes after the given node.
579
+
580
+ Args:
581
+ value: The value after which the new values are to be inserted.
582
+ new_values: The new values to be inserted.
583
+ """
584
+ if (value_id := id(value)) not in self._value_ids_to_boxes:
585
+ raise ValueError(f"Value {value!r} is not in the list")
586
+ insertion_point = self._value_ids_to_boxes[value_id]
587
+ return self._insert_many_after(insertion_point, new_values)
588
+
589
+ def insert_before(
590
+ self,
591
+ value: _T,
592
+ new_values: Iterable[_T],
593
+ ) -> None:
594
+ """Insert new nodes before the given node.
595
+
596
+ Args:
597
+ value: The value before which the new values are to be inserted.
598
+ new_values: The new values to be inserted.
599
+ """
600
+ if (value_id := id(value)) not in self._value_ids_to_boxes:
601
+ raise ValueError(f"Value {value!r} is not in the list")
602
+ insertion_point = self._value_ids_to_boxes[value_id].prev
603
+ return self._insert_many_after(insertion_point, new_values)
604
+
605
+ def __repr__(self) -> str:
606
+ return f"DoublyLinkedSet({list(self)})"
linkedset/py.typed ADDED
File without changes
@@ -0,0 +1,199 @@
1
+ Metadata-Version: 2.4
2
+ Name: linkedset
3
+ Version: 0.1.0
4
+ Summary: Ordered set robust against mutation during iteration in Python
5
+ Project-URL: Homepage, https://github.com/justinchuby/linkedset
6
+ Project-URL: Documentation, https://justinchuby.github.io/linkedset/
7
+ Project-URL: Repository, https://github.com/justinchuby/linkedset
8
+ Project-URL: Issues, https://github.com/justinchuby/linkedset/issues
9
+ Author: Justin Chu
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: data-structure,iteration,linked-list,ordered-set
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.9
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=7; extra == 'dev'
25
+ Requires-Dist: ruff; extra == 'dev'
26
+ Provides-Extra: docs
27
+ Requires-Dist: furo; extra == 'docs'
28
+ Requires-Dist: myst-parser; extra == 'docs'
29
+ Requires-Dist: sphinx>=7; extra == 'docs'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # linkedset
33
+
34
+ An ordered set that is robust against mutation during iteration, implemented in pure Python.
35
+
36
+ `DoublyLinkedSet` behaves like an ordered set backed by a doubly linked list. Inserting
37
+ and removing elements is `O(1)`, and — unlike Python's built-in `list` or `set` — you can
38
+ safely add, remove, or move elements *while iterating over the container*.
39
+
40
+ It implements both `collections.abc.Sequence` (ordered, indexable) and
41
+ `collections.abc.MutableSet` (set algebra and in-place updates).
42
+
43
+ 📖 **Documentation**: <https://justinchuby.github.io/linkedset/>
44
+
45
+ ## Installation
46
+
47
+ ```bash
48
+ pip install linkedset
49
+ ```
50
+
51
+ Or install from source:
52
+
53
+ ```bash
54
+ git clone https://github.com/justinchuby/linkedset
55
+ cd linkedset
56
+ pip install -e ".[dev]"
57
+ ```
58
+
59
+ ## Usage
60
+
61
+ ```python
62
+ from linkedset import DoublyLinkedSet
63
+
64
+ s = DoublyLinkedSet(["a", "b", "c"])
65
+
66
+ s.append("d") # -> a, b, c, d
67
+ s.insert_after("a", ["x"]) # -> a, x, b, c, d
68
+ s.insert_before("b", ["y"]) # -> a, x, y, b, c, d
69
+ s.remove("c") # -> a, x, y, b, d
70
+
71
+ print(s[0]) # "a" (O(1))
72
+ print(s[-1]) # "d" (O(1))
73
+ print(list(s)) # ['a', 'x', 'y', 'b', 'd']
74
+ ```
75
+
76
+ ### Safe mutation during iteration
77
+
78
+ ```python
79
+ s = DoublyLinkedSet(["a", "b", "c"])
80
+ for x in s:
81
+ if x == "a":
82
+ s.insert_after("a", ["d"]) # inserted after current -> iterated
83
+ s.remove("b") # removed before reached -> skipped
84
+ # Iterated: a, d, c
85
+ ```
86
+
87
+ Iteration rules:
88
+
89
+ - Elements inserted **after** the current node **are** iterated over.
90
+ - Elements inserted **before** the current node are **not** iterated over in the current pass.
91
+ - If the current node is moved to a different location, iteration continues from the node
92
+ that followed it at its *original* location.
93
+
94
+ Per-element mutation (`add`, `remove`, `discard`, `append`, `insert`, `clear`, …) is safe
95
+ during iteration. The **global reorders** `reverse()` and `rotate()` are *not* — calling them
96
+ mid-iteration may cause an in-progress iterator to skip or repeat elements.
97
+
98
+ ### Set operations
99
+
100
+ Because it is a `MutableSet`, the usual set algebra works and returns a new
101
+ `DoublyLinkedSet` (order preserved):
102
+
103
+ ```python
104
+ a = DoublyLinkedSet(["a", "b", "c"])
105
+ b = DoublyLinkedSet(["c", "d"])
106
+
107
+ a | b # union -> a, b, c, d
108
+ a & b # intersection -> c
109
+ a - b # difference -> a, b
110
+ a ^ b # symmetric -> a, b, d
111
+
112
+ a.add("x") # idempotent add (no-op if already present)
113
+ a.discard("z") # remove if present, never raises
114
+ a.pop() # remove and return the last element (list-style; pass an index too)
115
+ a |= b # in-place update
116
+
117
+ # `==` is order-sensitive, because the set is ordered:
118
+ DoublyLinkedSet(["a", "b"]) == DoublyLinkedSet(["a", "b"]) # True
119
+ DoublyLinkedSet(["a", "b"]) == DoublyLinkedSet(["b", "a"]) # False
120
+ ```
121
+
122
+ ### Deque- and list-style methods
123
+
124
+ Because it is ordered, it also offers the familiar `deque`/`list` mutators (all keeping the
125
+ set's uniqueness and identity semantics):
126
+
127
+ ```python
128
+ s = DoublyLinkedSet(["a", "b", "c"])
129
+
130
+ s.appendleft("z") # -> z, a, b, c
131
+ s.extendleft(["x", "y"])# -> y, x, z, a, b, c (prepended, reversed like deque)
132
+ s.popleft() # removes and returns "y"
133
+ s.pop() # removes and returns the last element ("c")
134
+ s.pop(1) # removes and returns the element at index 1
135
+ s.insert(1, "q") # insert before index 1 (index clamped like list.insert)
136
+ s.rotate(1) # rotate right; negative rotates left
137
+ s.reverse() # reverse in place
138
+ s2 = s.copy() # shallow copy, order preserved
139
+ ```
140
+
141
+ ## Semantics
142
+
143
+ - Membership and set operations are based on object **identity** (`id(value)`), not equality.
144
+ Two distinct objects that compare equal are treated as different elements.
145
+ - `==` is **order-sensitive** (it is an *ordered* set): equal only when the same elements, by
146
+ identity, appear in the same order. Instances are **not hashable** (mutable set).
147
+ Ordering/subset comparisons (`<`, `<=`, `>`, `>=`) are **not supported** (they raise
148
+ `TypeError`), since a subset relation would be ambiguous next to order-sensitive equality;
149
+ use the set algebra (`&`, `|`, `-`, `^`) or `isdisjoint()` instead.
150
+ - `None` is not a valid value.
151
+ - Accessing by index is `O(n)`, except the ends (`s[0]`, `s[-1]`) which are `O(1)`.
152
+
153
+ ## Complexity
154
+
155
+ `DoublyLinkedSet` is a doubly linked list paired with a `dict` mapping each element's
156
+ `id()` to its list node. That combination gives set-like `O(1)` membership and endpoint
157
+ mutation, while preserving order and safe mutation during iteration.
158
+
159
+ Let `n` be the size of the set (and `m` the size of the other operand for binary set
160
+ operations).
161
+
162
+ | Operation | Complexity | Notes |
163
+ | --- | --- | --- |
164
+ | `x in s`, `s.count(x)` | `O(1)` | `dict` lookup by `id(x)` |
165
+ | `len(s)` | `O(1)` | length is tracked, not counted |
166
+ | `s.append(x)`, `s.add(x)`, `s.appendleft(x)` | `O(1)` | insert at a known end |
167
+ | `s.remove(x)`, `s.discard(x)` | `O(1)` | unlink the node, no shifting |
168
+ | `s.extend(xs)`, `s.extendleft(xs)` | `O(k)` | `k = len(xs)`; `O(1)` per element |
169
+ | `s.insert_after(v, xs)`, `s.insert_before(v, xs)` | `O(k)` | `k = len(xs)`; `O(1)` per element |
170
+ | `s.pop()`, `s.popleft()` | `O(1)` | remove from an end |
171
+ | `s.pop(i)`, `s.insert(i, x)` | `O(\|i\|)` | walks to the index from the nearer end |
172
+ | `s[0]`, `s[-1]` | `O(1)` | endpoints are reachable directly |
173
+ | `s[i]` | `O(\|i\|)` | walks from the nearer end |
174
+ | `s.index(x)` | `O(n)` | linear scan for position |
175
+ | `s.rotate(n)` | `O(n mod len(s))` | relink only, no node churn |
176
+ | `s.reverse()`, `s.copy()`, `s.clear()` | `O(n)` | |
177
+ | iteration, `reversed(s)`, `s == other` | `O(n)` | |
178
+ | `s[a:b]` (slice) | `O(n)` | materialises a tuple |
179
+ | `s \| t`, `s & t`, `s - t`, `s ^ t` | `O(n + m)` | each membership test is `O(1)` |
180
+ | `s.isdisjoint(t)` | `O(n)` | one pass with `O(1)` lookups |
181
+
182
+ Space is `O(n)`: every element is wrapped in a small link node and referenced once from the
183
+ `id`-keyed index.
184
+
185
+ Mutating during iteration stays `O(1)` per operation. Removed nodes are unlinked from their
186
+ neighbours immediately, so a traversal never pays to skip over dead nodes — the only cost is
187
+ following the `next` pointer you already hold.
188
+
189
+ ## Development
190
+
191
+ ```bash
192
+ pip install -e ".[dev]"
193
+ python -m pytest # run tests
194
+ python -m ruff check # lint
195
+ ```
196
+
197
+ ## License
198
+
199
+ MIT. Portions derived from the ONNX Project Contributors (Apache-2.0); see `linkedset/__init__.py`.
@@ -0,0 +1,6 @@
1
+ linkedset/__init__.py,sha256=56ZBs9dwjCFopipD0KZyRGZ5Hx4qGn5J3FLUjOiF_5o,23253
2
+ linkedset/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ linkedset-0.1.0.dist-info/METADATA,sha256=X8Wk5mps2SCCDzSF7OZTHJtSdukv_4hQH5Xa1XB5M9g,7672
4
+ linkedset-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
5
+ linkedset-0.1.0.dist-info/licenses/LICENSE,sha256=SjqpNtkAu8i5L-1mHSpuWDPmthnFqwIMpsicZMARlhM,1067
6
+ linkedset-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Justin Chu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.