rangeable 1.0.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.
rangeable/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ """Rangeable — hashable-element interval set with first-insert ordered active queries.
2
+
3
+ Reference Python implementation of the language-neutral Rangeable spec.
4
+ See https://github.com/ZhgChgLi/RangeableRFC for the normative document.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ._core import Rangeable
10
+ from ._errors import InvalidIntervalError, RangeableError
11
+ from ._interval import Interval
12
+ from ._slot import Slot
13
+ from ._transition import TransitionEvent, TransitionKind
14
+
15
+ __version__ = "1.0.0"
16
+
17
+ __all__ = [
18
+ "Rangeable",
19
+ "Interval",
20
+ "Slot",
21
+ "TransitionEvent",
22
+ "TransitionKind",
23
+ "RangeableError",
24
+ "InvalidIntervalError",
25
+ "__version__",
26
+ ]
@@ -0,0 +1,210 @@
1
+ """Lazy boundary-event index per RFC §5.2 / §6.3."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Generic, Hashable, TypeVar
7
+
8
+ from ._transition import TransitionEvent, TransitionKind
9
+
10
+ E = TypeVar("E", bound=Hashable)
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class _RawEvent(Generic[E]):
15
+ """Internal event carrying the ord tiebreaker. Public API exposes
16
+ :class:`TransitionEvent` (without ord)."""
17
+
18
+ coordinate: int | None
19
+ kind: TransitionKind
20
+ element: E
21
+ ord: int
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class Segment(Generic[E]):
26
+ """One maximal run of integers over which the active set is constant."""
27
+
28
+ lo: int
29
+ hi: int
30
+ active: tuple[E, ...]
31
+
32
+
33
+ def _compare_coord(a: int | None, b: int | None) -> int:
34
+ """Total order over coordinates: ``None`` (== +∞) is greater than any
35
+ finite int. Returns -1 / 0 / +1.
36
+ """
37
+ if a is None and b is None:
38
+ return 0
39
+ if a is None:
40
+ return 1
41
+ if b is None:
42
+ return -1
43
+ if a < b:
44
+ return -1
45
+ if a > b:
46
+ return 1
47
+ return 0
48
+
49
+
50
+ def _coord_le(coord: int | None, upper: int | None) -> bool:
51
+ return _compare_coord(coord, upper) <= 0
52
+
53
+
54
+ def _coord_ge(coord: int | None, threshold: int | None) -> bool:
55
+ return _compare_coord(coord, threshold) >= 0
56
+
57
+
58
+ class BoundaryIndex(Generic[E]):
59
+ """Built from a snapshot of the per-element interval map plus the
60
+ insertion-order map ``ord``. Carries:
61
+
62
+ * ``events`` — sorted tuple of :class:`TransitionEvent` under §4.5
63
+ ordering (without the internal ``ord`` field).
64
+ * ``segments`` — sorted, disjoint tuple of :class:`Segment` covering
65
+ every coordinate at which the active set is non-empty. Active sets
66
+ are sorted by ``ord(e)`` ascending.
67
+ * ``version`` — snapshot of :class:`Rangeable` version at build time.
68
+
69
+ The owner :class:`Rangeable` invalidates the index by setting its
70
+ reference to ``None`` on any mutation; reads compare versions to
71
+ decide whether to rebuild (T3 mutex pattern, §11).
72
+ """
73
+
74
+ __slots__ = ("events", "segments", "version", "_raw_events")
75
+
76
+ def __init__(
77
+ self,
78
+ events: tuple[TransitionEvent[E], ...],
79
+ segments: tuple[Segment[E], ...],
80
+ version: int,
81
+ raw_events: tuple[_RawEvent[E], ...],
82
+ ) -> None:
83
+ self.events = events
84
+ self.segments = segments
85
+ self.version = version
86
+ self._raw_events = raw_events
87
+
88
+ def segment_at(self, coord: int) -> Segment[E] | None:
89
+ """Find the segment containing ``coord``, or ``None`` if none.
90
+ O(log |segments|). ``coord`` must be a finite int.
91
+ """
92
+ segs = self.segments
93
+ lo, hi = 0, len(segs)
94
+ while lo < hi:
95
+ mid = (lo + hi) // 2
96
+ if segs[mid].hi >= coord:
97
+ hi = mid
98
+ else:
99
+ lo = mid + 1
100
+ if lo >= len(segs):
101
+ return None
102
+ seg = segs[lo]
103
+ return seg if seg.lo <= coord else None
104
+
105
+ def events_in_range(
106
+ self, lo: int, upper_coord: int | None
107
+ ) -> list[TransitionEvent[E]]:
108
+ """Returns events whose coordinate falls in ``[lo, upper_coord]``.
109
+ ``upper_coord`` may be ``None`` to mean +∞.
110
+ """
111
+ events = self.events
112
+ n = len(events)
113
+ # Binary search for first index i where events[i].coordinate >= lo.
114
+ l, r = 0, n
115
+ while l < r:
116
+ m = (l + r) // 2
117
+ if _coord_ge(events[m].coordinate, lo):
118
+ r = m
119
+ else:
120
+ l = m + 1
121
+ result: list[TransitionEvent[E]] = []
122
+ i = l
123
+ while i < n and _coord_le(events[i].coordinate, upper_coord):
124
+ result.append(events[i])
125
+ i += 1
126
+ return result
127
+
128
+ @classmethod
129
+ def build(
130
+ cls,
131
+ intervals: dict, # element -> DisjointSet
132
+ ord_map: dict, # element -> int
133
+ snapshot_version: int,
134
+ int_max_sentinel: int | None = None,
135
+ ) -> "BoundaryIndex[E]":
136
+ """Build a fresh index. ``int_max_sentinel`` (default ``None``) lets
137
+ the caller opt into "treat ``hi == sentinel`` as +∞" semantics for
138
+ cross-language fixture parity with bounded-int languages.
139
+ """
140
+ raw: list[_RawEvent] = []
141
+ for element, ds in intervals.items():
142
+ element_ord = ord_map[element]
143
+ for iv in ds:
144
+ raw.append(
145
+ _RawEvent(iv.lo, TransitionKind.OPEN, element, element_ord)
146
+ )
147
+ if int_max_sentinel is not None and iv.hi == int_max_sentinel:
148
+ close_coord: int | None = None
149
+ else:
150
+ close_coord = iv.hi + 1
151
+ raw.append(
152
+ _RawEvent(close_coord, TransitionKind.CLOSE, element, element_ord)
153
+ )
154
+
155
+ # Sort: coord ascending (None > finite); same-coord opens before
156
+ # closes; same-coord-and-kind opens by ord asc, closes by ord desc.
157
+ def sort_key(ev: _RawEvent) -> tuple:
158
+ # First component: (1, 0) for None (treat as greater than any
159
+ # finite); (0, coord) for finite. Tuple comparison handles it.
160
+ if ev.coordinate is None:
161
+ coord_key: tuple = (1, 0)
162
+ else:
163
+ coord_key = (0, ev.coordinate)
164
+ kind_key = 0 if ev.kind == TransitionKind.OPEN else 1
165
+ ord_tiebreak = ev.ord if ev.kind == TransitionKind.OPEN else -ev.ord
166
+ return (coord_key, kind_key, ord_tiebreak)
167
+
168
+ raw.sort(key=sort_key)
169
+
170
+ public_events = tuple(
171
+ TransitionEvent(ev.coordinate, ev.kind, ev.element) for ev in raw
172
+ )
173
+ segments = cls._materialise_segments(raw)
174
+ return cls(public_events, segments, snapshot_version, tuple(raw))
175
+
176
+ @staticmethod
177
+ def _materialise_segments(events: list[_RawEvent]) -> tuple[Segment, ...]:
178
+ """Sweep events linearly, materialising a Segment for every maximal
179
+ run of integers over which the active set is constant. Per RFC §6.3
180
+ we do not emit a segment whose active set is empty.
181
+ """
182
+ segments: list[Segment] = []
183
+ active_by_ord: dict[int, object] = {}
184
+ prev_coord: int | None = None
185
+ i = 0
186
+ n = len(events)
187
+ while i < n:
188
+ coord = events[i].coordinate
189
+
190
+ # Emit segment for [prev_coord, coord-1] before processing
191
+ # events at this coord, if the active set is non-empty.
192
+ if prev_coord is not None and active_by_ord and coord is not None:
193
+ seg_hi = coord - 1
194
+ snapshot = tuple(
195
+ active_by_ord[o] for o in sorted(active_by_ord.keys())
196
+ )
197
+ segments.append(Segment(prev_coord, seg_hi, snapshot))
198
+
199
+ # Apply every event at this coord.
200
+ while i < n and events[i].coordinate == coord:
201
+ ev_i = events[i]
202
+ if ev_i.kind == TransitionKind.OPEN:
203
+ active_by_ord[ev_i.ord] = ev_i.element
204
+ else:
205
+ active_by_ord.pop(ev_i.ord, None)
206
+ i += 1
207
+
208
+ prev_coord = coord
209
+
210
+ return tuple(segments)
rangeable/_core.py ADDED
@@ -0,0 +1,179 @@
1
+ """Main Rangeable container."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Generic, Hashable, Iterator, TypeVar
6
+
7
+ from ._boundary_index import BoundaryIndex
8
+ from ._disjoint_set import DisjointSet, InsertResult
9
+ from ._errors import InvalidIntervalError
10
+ from ._slot import Slot
11
+ from ._transition import TransitionEvent
12
+
13
+ E = TypeVar("E", bound=Hashable)
14
+
15
+ _EMPTY_OBJS: tuple = ()
16
+
17
+
18
+ class Rangeable(Generic[E]):
19
+ """Generic, integer-coordinate, closed-interval set container.
20
+
21
+ Pairs hashable elements with their merged disjoint integer ranges
22
+ and supports three query families:
23
+
24
+ * by-element via :meth:`get_range`
25
+ * by-position via ``r[i]`` / :meth:`active_at`
26
+ * by-range via :meth:`transitions`
27
+
28
+ See `RFC §3 <https://github.com/ZhgChgLi/RangeableRFC>`_ for the
29
+ full normative API surface.
30
+ """
31
+
32
+ __slots__ = (
33
+ "_intervals",
34
+ "_insertion_order",
35
+ "_ord",
36
+ "_version",
37
+ "_event_index",
38
+ )
39
+
40
+ def __init__(self) -> None:
41
+ self._intervals: dict[E, DisjointSet] = {}
42
+ self._insertion_order: list[E] = []
43
+ self._ord: dict[E, int] = {}
44
+ self._version: int = 0
45
+ self._event_index: BoundaryIndex[E] | None = None
46
+
47
+ @classmethod
48
+ def empty(cls) -> "Rangeable[E]":
49
+ """Sugar matching the RFC §3.1 ``Rangeable.empty()`` alias."""
50
+ return cls()
51
+
52
+ @property
53
+ def version(self) -> int:
54
+ return self._version
55
+
56
+ def insert(self, element: E, *, start: int, end: int) -> "Rangeable[E]":
57
+ """Insert ``element`` covering the closed interval ``[start, end]``.
58
+
59
+ Idempotent per RFC §3.2: re-inserting a sub-range that is already
60
+ fully contained leaves the container unchanged and does NOT bump
61
+ :attr:`version`.
62
+
63
+ Raises :class:`InvalidIntervalError` if ``start > end``.
64
+
65
+ Returns ``self`` for chaining.
66
+ """
67
+ if start > end:
68
+ raise InvalidIntervalError(f"start ({start}) > end ({end})")
69
+
70
+ ds = self._intervals.get(element)
71
+ if ds is None:
72
+ ds = DisjointSet()
73
+ self._intervals[element] = ds
74
+ self._insertion_order.append(element)
75
+ self._ord[element] = len(self._insertion_order)
76
+
77
+ result = ds.insert(start, end)
78
+ if result == InsertResult.MUTATED:
79
+ self._version += 1
80
+ self._event_index = None
81
+ return self
82
+
83
+ def __getitem__(self, i: int) -> Slot[E]:
84
+ """Active-element list at ``i``. RFC §3.3.
85
+
86
+ O(log |segments| + r) once the index is built. Returns an empty
87
+ :class:`Slot` for coordinates outside every segment.
88
+ """
89
+ self._ensure_event_index_fresh()
90
+ assert self._event_index is not None
91
+ seg = self._event_index.segment_at(i)
92
+ if seg is None:
93
+ return Slot(_EMPTY_OBJS)
94
+ return Slot(seg.active)
95
+
96
+ def active_at(self, *, index: int) -> Slot[E]:
97
+ """Same as ``self[index]``, named to match RFC §3.3."""
98
+ return self[index]
99
+
100
+ def get_range(self, element: E) -> list[tuple[int, int]]:
101
+ """Merged ranges for ``element`` as ``[(lo, hi), ...]``. RFC §3.4.
102
+
103
+ Returns an empty list when the element has never been inserted.
104
+ """
105
+ ds = self._intervals.get(element)
106
+ if ds is None:
107
+ return []
108
+ return ds.to_pairs()
109
+
110
+ def transitions(self, *, lo: int, hi: int | None) -> list[TransitionEvent[E]]:
111
+ """Open / close events within the inclusive coordinate range
112
+ ``[lo, hi]``. RFC §3.5.
113
+
114
+ ``hi=None`` means +∞ (include all events through the upper bound).
115
+
116
+ Raises :class:`InvalidIntervalError` if ``lo > hi`` or ``lo`` is
117
+ ``None``.
118
+ """
119
+ if lo is None:
120
+ raise InvalidIntervalError("transitions: lo must not be None")
121
+ if hi is not None and lo > hi:
122
+ raise InvalidIntervalError(f"lo ({lo}) > hi ({hi})")
123
+
124
+ self._ensure_event_index_fresh()
125
+ assert self._event_index is not None
126
+ upper = None if hi is None else hi + 1
127
+ return self._event_index.events_in_range(lo, upper)
128
+
129
+ def __len__(self) -> int:
130
+ """Number of distinct equivalence-class elements ever inserted."""
131
+ return len(self._insertion_order)
132
+
133
+ @property
134
+ def count(self) -> int:
135
+ return len(self._insertion_order)
136
+
137
+ @property
138
+ def empty(self) -> bool:
139
+ return not self._insertion_order
140
+
141
+ def __bool__(self) -> bool:
142
+ return bool(self._insertion_order)
143
+
144
+ def __iter__(self) -> Iterator[tuple[E, list[tuple[int, int]]]]:
145
+ """Yield ``(element, ranges)`` pairs in insertion-order ascending."""
146
+ for element in self._insertion_order:
147
+ yield element, self._intervals[element].to_pairs()
148
+
149
+ def copy(self) -> "Rangeable[E]":
150
+ """Deep copy. Mutation on the copy MUST NOT affect this instance,
151
+ and vice versa.
152
+ """
153
+ dup = Rangeable[E]()
154
+ for element in self._insertion_order:
155
+ dup._replant(element, self._intervals[element], self._ord[element])
156
+ dup._version = self._version
157
+ return dup
158
+
159
+ def __copy__(self) -> "Rangeable[E]":
160
+ return self.copy()
161
+
162
+ def __deepcopy__(self, memo: dict) -> "Rangeable[E]":
163
+ return self.copy()
164
+
165
+ def _ensure_event_index_fresh(self) -> None:
166
+ if self._event_index is not None and self._event_index.version == self._version:
167
+ return
168
+ v_start = self._version
169
+ rebuilt = BoundaryIndex.build(self._intervals, self._ord, v_start)
170
+ if self._version == v_start:
171
+ self._event_index = rebuilt
172
+
173
+ def _replant(self, element: E, source_set: DisjointSet, source_ord: int) -> None:
174
+ new_set = DisjointSet()
175
+ for iv in source_set:
176
+ new_set.insert(iv.lo, iv.hi)
177
+ self._intervals[element] = new_set
178
+ self._insertion_order.append(element)
179
+ self._ord[element] = source_ord
@@ -0,0 +1,104 @@
1
+ """Sorted, disjoint, non-adjacent merged-interval list for one element."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import bisect
6
+ from enum import Enum
7
+ from typing import Iterator
8
+
9
+ from ._errors import InvalidIntervalError
10
+ from ._interval import Interval
11
+
12
+
13
+ class InsertResult(Enum):
14
+ """Outcome of :meth:`DisjointSet.insert`. The owning :class:`Rangeable`
15
+ bumps its version counter only on ``MUTATED``; ``IDEMPOTENT`` means the
16
+ insert was absorbed and the canonical state is unchanged (RFC Test #21,
17
+ Lemma 6.5.B).
18
+ """
19
+
20
+ MUTATED = "mutated"
21
+ IDEMPOTENT = "idempotent"
22
+
23
+
24
+ class DisjointSet:
25
+ """Maintains the RFC §5.1 (I1) invariant for one element:
26
+
27
+ * sorted by ``lo`` strictly ascending
28
+ * any two adjacent entries ``(lo1, hi1), (lo2, hi2)`` satisfy
29
+ ``hi1 + 1 < lo2`` (no overlap, no integer adjacency)
30
+ * ``lo <= hi`` for every entry
31
+
32
+ Mirrors the Ruby reference implementation line-for-line, including
33
+ the §6.1 cleaner-variant containment fast-path.
34
+ """
35
+
36
+ __slots__ = ("_entries",)
37
+
38
+ def __init__(self) -> None:
39
+ self._entries: list[Interval] = []
40
+
41
+ def __len__(self) -> int:
42
+ return len(self._entries)
43
+
44
+ def __iter__(self) -> Iterator[Interval]:
45
+ return iter(self._entries)
46
+
47
+ @property
48
+ def empty(self) -> bool:
49
+ return not self._entries
50
+
51
+ def to_pairs(self) -> list[tuple[int, int]]:
52
+ """Snapshot the merged intervals as ``[(lo, hi), ...]``."""
53
+ return [(iv.lo, iv.hi) for iv in self._entries]
54
+
55
+ def insert(self, lo: int, hi: int) -> InsertResult:
56
+ """Insert ``[lo, hi]`` into the set, performing union-with-merge per
57
+ RFC §6.1.
58
+
59
+ Returns :attr:`InsertResult.MUTATED` if the canonical state changed
60
+ (caller should bump version), :attr:`InsertResult.IDEMPOTENT` if the
61
+ insert was absorbed by an existing entry (caller MUST NOT bump
62
+ version, per Test #21 and Lemma 6.5.B).
63
+ """
64
+ if lo > hi:
65
+ raise InvalidIntervalError(f"lo ({lo}) > hi ({hi})")
66
+
67
+ # Step 4 of §6.1: bsearch for the leftmost touch candidate.
68
+ # Predicate: ``iv.hi + 1 >= lo``. We use ``iv.hi + 1`` (not
69
+ # ``lo - 1``) to avoid Integer underflow at ``lo == Int.min``
70
+ # boundaries (§4.7 C5). Python ints are unbounded but we mirror
71
+ # the Ruby form for cross-language byte parity.
72
+ i0 = bisect.bisect_left(
73
+ self._entries, lo, key=lambda iv: iv.hi + 1
74
+ )
75
+
76
+ # Step 5: collect contiguous touch entries while
77
+ # ``entries[i].lo <= hi + 1``.
78
+ to_merge_end = i0
79
+ n = len(self._entries)
80
+ while to_merge_end < n and self._entries[to_merge_end].lo <= hi + 1:
81
+ to_merge_end += 1
82
+ merge_count = to_merge_end - i0
83
+
84
+ # Step 6: containment idempotent fast-path. If we touch exactly one
85
+ # existing entry that fully covers [lo, hi], this insert is a no-op.
86
+ # MUST NOT mutate, MUST NOT bump version.
87
+ if merge_count == 1:
88
+ existing = self._entries[i0]
89
+ if existing.lo <= lo and hi <= existing.hi:
90
+ return InsertResult.IDEMPOTENT
91
+
92
+ # Step 7: real mutation path. Compute merged bounds, splice in.
93
+ new_lo = lo
94
+ new_hi = hi
95
+ if merge_count > 0:
96
+ first = self._entries[i0]
97
+ last = self._entries[to_merge_end - 1]
98
+ if first.lo < new_lo:
99
+ new_lo = first.lo
100
+ if last.hi > new_hi:
101
+ new_hi = last.hi
102
+ merged = Interval(new_lo, new_hi)
103
+ self._entries[i0:to_merge_end] = [merged]
104
+ return InsertResult.MUTATED
rangeable/_errors.py ADDED
@@ -0,0 +1,15 @@
1
+ """Error types for Rangeable."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class RangeableError(ValueError):
7
+ """Base class for Rangeable errors. Subclasses ValueError so callers can
8
+ catch generic value-related issues alongside Rangeable-specific ones.
9
+ """
10
+
11
+
12
+ class InvalidIntervalError(RangeableError):
13
+ """Raised when an interval is malformed (start > end), or a transitions
14
+ query range is malformed (lo > hi, or lo is None). RFC §3.7 / §3.2 / §3.5.
15
+ """
rangeable/_interval.py ADDED
@@ -0,0 +1,29 @@
1
+ """Closed integer interval [lo, hi]."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from ._errors import InvalidIntervalError
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class Interval:
12
+ """Immutable closed integer interval [lo, hi].
13
+
14
+ Both ends are inclusive, matching RFC §4.1. ``lo > hi`` raises
15
+ :class:`InvalidIntervalError` at construction time.
16
+ """
17
+
18
+ lo: int
19
+ hi: int
20
+
21
+ def __post_init__(self) -> None:
22
+ if self.lo > self.hi:
23
+ raise InvalidIntervalError(f"lo ({self.lo}) > hi ({self.hi})")
24
+
25
+ def __contains__(self, coord: int) -> bool:
26
+ return self.lo <= coord <= self.hi
27
+
28
+ def to_tuple(self) -> tuple[int, int]:
29
+ return (self.lo, self.hi)
rangeable/_slot.py ADDED
@@ -0,0 +1,33 @@
1
+ """Active-element list returned by ``Rangeable[i]``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Generic, Iterator, TypeVar
7
+
8
+ E = TypeVar("E")
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class Slot(Generic[E]):
13
+ """Wraps the ordered tuple of elements active at a coordinate.
14
+
15
+ ``objs`` is sorted by first-insertion order ascending (RFC §4.5).
16
+ The same coordinate within an unmutated container always returns
17
+ an equal ``Slot``.
18
+ """
19
+
20
+ objs: tuple[E, ...]
21
+
22
+ def __len__(self) -> int:
23
+ return len(self.objs)
24
+
25
+ def __iter__(self) -> Iterator[E]:
26
+ return iter(self.objs)
27
+
28
+ def __bool__(self) -> bool:
29
+ return bool(self.objs)
30
+
31
+ @property
32
+ def empty(self) -> bool:
33
+ return not self.objs
@@ -0,0 +1,41 @@
1
+ """Transition events emitted by ``Rangeable.transitions``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import Enum
7
+ from typing import Generic, TypeVar
8
+
9
+ E = TypeVar("E")
10
+
11
+
12
+ class TransitionKind(str, Enum):
13
+ """Kind of a boundary event. Inherits from ``str`` so cross-language
14
+ JSON fixtures can compare directly against ``"open"`` / ``"close"``.
15
+ """
16
+
17
+ OPEN = "open"
18
+ CLOSE = "close"
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class TransitionEvent(Generic[E]):
23
+ """A single boundary event in coordinate-sorted order.
24
+
25
+ ``coordinate`` is normally an :class:`int`; it is ``None`` for close
26
+ events whose underlying interval ends at the implementation's +∞
27
+ sentinel (RFC §4.7 C4). Comparison treats ``None`` as greater than
28
+ any finite int.
29
+ """
30
+
31
+ coordinate: int | None
32
+ kind: TransitionKind
33
+ element: E
34
+
35
+ @property
36
+ def is_open(self) -> bool:
37
+ return self.kind == TransitionKind.OPEN
38
+
39
+ @property
40
+ def is_close(self) -> bool:
41
+ return self.kind == TransitionKind.CLOSE
rangeable/py.typed ADDED
File without changes
@@ -0,0 +1,131 @@
1
+ Metadata-Version: 2.4
2
+ Name: rangeable
3
+ Version: 1.0.0
4
+ Summary: Hashable-element interval set with first-insert ordered active queries.
5
+ Project-URL: Homepage, https://github.com/ZhgChgLi/PythonRangeable
6
+ Project-URL: Source, https://github.com/ZhgChgLi/PythonRangeable
7
+ Project-URL: Documentation, https://github.com/ZhgChgLi/RangeableRFC/blob/main/RFC.md
8
+ Project-URL: Issues, https://github.com/ZhgChgLi/PythonRangeable/issues
9
+ Project-URL: Changelog, https://github.com/ZhgChgLi/PythonRangeable/blob/main/CHANGELOG.md
10
+ Author-email: ZhgChgLi <zhgchgli@gmail.com>
11
+ License: MIT
12
+ License-File: LICENSE
13
+ Keywords: boundary-events,interval,merge,range,rfc
14
+ Classifier: Development Status :: 5 - Production/Stable
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Software Development :: Libraries
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.10
26
+ Provides-Extra: dev
27
+ Requires-Dist: build>=1.2; extra == 'dev'
28
+ Requires-Dist: pytest>=8; extra == 'dev'
29
+ Requires-Dist: twine>=5; extra == 'dev'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # PythonRangeable
33
+
34
+ [![PyPI](https://img.shields.io/pypi/v/rangeable.svg)](https://pypi.org/project/rangeable/)
35
+ [![Python](https://img.shields.io/pypi/pyversions/rangeable.svg)](https://pypi.org/project/rangeable/)
36
+ [![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
37
+
38
+ Reference Python implementation of [`Rangeable<Element>`](https://github.com/ZhgChgLi/RangeableRFC) — a generic, integer-coordinate, closed-interval set container with first-insert ordered active queries.
39
+
40
+ ## Installation
41
+
42
+ ```bash
43
+ pip install rangeable
44
+ ```
45
+
46
+ ## Usage
47
+
48
+ ```python
49
+ from dataclasses import dataclass
50
+ from rangeable import Rangeable
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class Strong: pass
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class Italic: pass
57
+
58
+ @dataclass(frozen=True, slots=True)
59
+ class Link:
60
+ url: str
61
+
62
+ r: Rangeable = Rangeable()
63
+ r.insert(Strong(), start=2, end=5)
64
+ r.insert(Strong(), start=3, end=7) # merges with [2, 5] → [2, 7]
65
+ r.insert(Strong(), start=9, end=11) # disjoint
66
+ r.insert(Italic(), start=3, end=8)
67
+
68
+ r.get_range(Strong()) # [(2, 7), (9, 11)]
69
+ r.get_range(Italic()) # [(3, 8)]
70
+
71
+ r[4].objs # (Strong(), Italic()) first-insert order
72
+ r[8].objs # (Italic(),)
73
+ r[10].objs # (Strong(),)
74
+ ```
75
+
76
+ ### Sweep iteration via transitions
77
+
78
+ ```python
79
+ for event in r.transitions(lo=0, hi=15):
80
+ print(event.coordinate, event.kind.value, event.element)
81
+ ```
82
+
83
+ ## API
84
+
85
+ | Member | Returns | Notes |
86
+ |---|---|---|
87
+ | `Rangeable()` | constructor | empty container |
88
+ | `r.insert(e, *, start, end)` | `Rangeable` (chainable) | raises `InvalidIntervalError` on `start > end` |
89
+ | `r[i]` | `Slot[E]` | `Slot.objs` is the active-set tuple |
90
+ | `r.get_range(e)` | `list[tuple[int, int]]` | merged disjoint ranges |
91
+ | `r.transitions(*, lo, hi)` | `list[TransitionEvent[E]]` | `hi=None` means +∞ |
92
+ | `r.count` / `len(r)` | `int` | distinct elements |
93
+ | `r.empty` / `bool(r)` | `bool` | |
94
+ | `iter(r)` | `Iterator[(E, list[(int, int)])]` | first-insert order |
95
+ | `r.copy()` | `Rangeable[E]` | deep copy |
96
+ | `r.version` | `int` | unchanged on idempotent insert |
97
+
98
+ ## Semantics
99
+
100
+ - **End is inclusive**: `[a, b]` covers `a..=b`, both ends.
101
+ - **Same-element merging**: equal elements (by `__eq__` + `__hash__`) merge on overlap or integer adjacency. `[2, 4] ∪ [5, 7] = [2, 7]`.
102
+ - **Idempotent insert**: re-inserting a contained interval does not bump `version`.
103
+ - **Out-of-order rejected**: `r.insert(e, start=5, end=2)` raises `InvalidIntervalError`.
104
+ - **Active-set ordering**: deterministic — first-insert order of the element.
105
+ - **Coordinate sentinel**: a close event for an interval ending at the optional `int_max` sentinel carries `coordinate is None` (None == +∞ per RFC §4.7). Python ints are unbounded, so this only matters when integrating with bounded-int languages; the fixture does not exercise it.
106
+
107
+ See [RangeableRFC](https://github.com/ZhgChgLi/RangeableRFC) § 4 for normative semantics and § 10 for the 23-case test contract.
108
+
109
+ ## Cross-language consistency
110
+
111
+ This Python implementation, the [Ruby implementation](https://github.com/ZhgChgLi/RubyRangeable), and the [Swift implementation](https://github.com/ZhgChgLi/SwiftRangeable) share a 160-op / 86-probe JSON fixture; all three produce byte-identical outputs.
112
+
113
+ ## See also
114
+
115
+ - **[RangeableRFC](https://github.com/ZhgChgLi/RangeableRFC)** — normative specification.
116
+ - **[RubyRangeable](https://github.com/ZhgChgLi/RubyRangeable)** — sibling Ruby reference implementation, published as the `rangeable` gem.
117
+ - **[SwiftRangeable](https://github.com/ZhgChgLi/SwiftRangeable)** — sibling Swift reference implementation.
118
+ - **[JSRangeable](https://github.com/ZhgChgLi/JSRangeable)** — sibling TypeScript reference implementation, published as the `rangeable-js` npm package.
119
+
120
+ ## Development
121
+
122
+ ```bash
123
+ python -m pip install -e ".[dev]"
124
+ pytest -q
125
+ ```
126
+
127
+ The suite covers the full RFC § 10 contract, the cross-language fixture replay, and a property test against a brute-force oracle.
128
+
129
+ ## License
130
+
131
+ MIT (c) ZhgChgLi
@@ -0,0 +1,13 @@
1
+ rangeable/__init__.py,sha256=YZNIh9PliiYJoi3YRNA9nJKMQomEost1Yt1DbE9nB34,673
2
+ rangeable/_boundary_index.py,sha256=NGC93Xx87Blx_B_g0XSGDRxV6iKvXKQ4diZkMnIFu_o,7255
3
+ rangeable/_core.py,sha256=x-vPnKhneWrI-eQk_JAMOIBktNTiUvISGOc7OT03rgs,5959
4
+ rangeable/_disjoint_set.py,sha256=OPHZtp1KUoSTfBPf90QNOIS8843Fo8yLaGeBK8Rl_Hg,3642
5
+ rangeable/_errors.py,sha256=BIxXcuSaJOye_aXYwDGca045l37kfmIeesKWLiZ0_ow,476
6
+ rangeable/_interval.py,sha256=ETp0kTch9oR8Uj49tcOdwySy2gA2DXZcoL10wMtrNPI,730
7
+ rangeable/_slot.py,sha256=UBneXqmBECBZNcqx9wyWQJqR4XWGCZEwwBKL5w8hDRI,786
8
+ rangeable/_transition.py,sha256=jN6FoHaIgNWkPrIQiu_SFX6MDUXaxWEycpm0kuwO0VM,1072
9
+ rangeable/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ rangeable-1.0.0.dist-info/METADATA,sha256=TkGqBCxRovADLUCAErM46n1iWbVc8I5XmSntTOkxTvA,5407
11
+ rangeable-1.0.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
12
+ rangeable-1.0.0.dist-info/licenses/LICENSE,sha256=Uf4vcasnGOV74S76bdjZYpcnTijoTHUTE7UCD8bP8Mo,1065
13
+ rangeable-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ZhgChgLi
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.