rangeable 1.0.0__tar.gz
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-1.0.0/.gitignore +33 -0
- rangeable-1.0.0/CHANGELOG.md +28 -0
- rangeable-1.0.0/LICENSE +21 -0
- rangeable-1.0.0/PKG-INFO +131 -0
- rangeable-1.0.0/README.md +100 -0
- rangeable-1.0.0/pyproject.toml +51 -0
- rangeable-1.0.0/src/rangeable/__init__.py +26 -0
- rangeable-1.0.0/src/rangeable/_boundary_index.py +210 -0
- rangeable-1.0.0/src/rangeable/_core.py +179 -0
- rangeable-1.0.0/src/rangeable/_disjoint_set.py +104 -0
- rangeable-1.0.0/src/rangeable/_errors.py +15 -0
- rangeable-1.0.0/src/rangeable/_interval.py +29 -0
- rangeable-1.0.0/src/rangeable/_slot.py +33 -0
- rangeable-1.0.0/src/rangeable/_transition.py +41 -0
- rangeable-1.0.0/src/rangeable/py.typed +0 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Byte-compiled / optimised
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# Distribution / packaging
|
|
7
|
+
build/
|
|
8
|
+
dist/
|
|
9
|
+
*.egg-info/
|
|
10
|
+
*.egg
|
|
11
|
+
.eggs/
|
|
12
|
+
|
|
13
|
+
# Virtual environments
|
|
14
|
+
.venv/
|
|
15
|
+
venv/
|
|
16
|
+
env/
|
|
17
|
+
|
|
18
|
+
# Test / coverage
|
|
19
|
+
.pytest_cache/
|
|
20
|
+
.coverage
|
|
21
|
+
.coverage.*
|
|
22
|
+
htmlcov/
|
|
23
|
+
.tox/
|
|
24
|
+
.mypy_cache/
|
|
25
|
+
.ruff_cache/
|
|
26
|
+
|
|
27
|
+
# Editor / OS
|
|
28
|
+
.idea/
|
|
29
|
+
.vscode/
|
|
30
|
+
.DS_Store
|
|
31
|
+
|
|
32
|
+
# Build artefacts
|
|
33
|
+
wheelhouse/
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented here. The format is
|
|
4
|
+
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this
|
|
5
|
+
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [1.0.0] - 2026-05-10
|
|
8
|
+
|
|
9
|
+
Initial public release of the Python reference implementation of the
|
|
10
|
+
[Rangeable RFC](https://github.com/ZhgChgLi/RangeableRFC).
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `Rangeable[E]` generic container with the full RFC §3 API:
|
|
14
|
+
`insert`, `__getitem__` / `active_at`, `get_range`, `transitions`,
|
|
15
|
+
`copy`, iteration over `(element, ranges)` pairs, `len`, `__bool__`,
|
|
16
|
+
`version`, `count`, `empty`.
|
|
17
|
+
- `Interval`, `Slot`, `TransitionEvent`, `TransitionKind` value types
|
|
18
|
+
(frozen dataclasses with slots).
|
|
19
|
+
- `RangeableError` (subclass of `ValueError`) and
|
|
20
|
+
`InvalidIntervalError` (subclass of `RangeableError`).
|
|
21
|
+
- PEP 561 `py.typed` marker for downstream type checkers.
|
|
22
|
+
|
|
23
|
+
### Verified
|
|
24
|
+
- 23 RFC §10 contract tests.
|
|
25
|
+
- 86 cross-language probes against the shared 160-op fixture (sha256
|
|
26
|
+
`316ac8619fd632174b2374ed2137348e8d744e3904b002761d0dbdce38ea2edf`,
|
|
27
|
+
byte-identical to the Ruby and Swift fixtures).
|
|
28
|
+
- Property test against a brute-force oracle over 1000 random ops.
|
rangeable-1.0.0/LICENSE
ADDED
|
@@ -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.
|
rangeable-1.0.0/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/rangeable/)
|
|
35
|
+
[](https://pypi.org/project/rangeable/)
|
|
36
|
+
[](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,100 @@
|
|
|
1
|
+
# PythonRangeable
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/rangeable/)
|
|
4
|
+
[](https://pypi.org/project/rangeable/)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
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.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install rangeable
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from rangeable import Rangeable
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class Strong: pass
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class Italic: pass
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class Link:
|
|
29
|
+
url: str
|
|
30
|
+
|
|
31
|
+
r: Rangeable = Rangeable()
|
|
32
|
+
r.insert(Strong(), start=2, end=5)
|
|
33
|
+
r.insert(Strong(), start=3, end=7) # merges with [2, 5] → [2, 7]
|
|
34
|
+
r.insert(Strong(), start=9, end=11) # disjoint
|
|
35
|
+
r.insert(Italic(), start=3, end=8)
|
|
36
|
+
|
|
37
|
+
r.get_range(Strong()) # [(2, 7), (9, 11)]
|
|
38
|
+
r.get_range(Italic()) # [(3, 8)]
|
|
39
|
+
|
|
40
|
+
r[4].objs # (Strong(), Italic()) first-insert order
|
|
41
|
+
r[8].objs # (Italic(),)
|
|
42
|
+
r[10].objs # (Strong(),)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Sweep iteration via transitions
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
for event in r.transitions(lo=0, hi=15):
|
|
49
|
+
print(event.coordinate, event.kind.value, event.element)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## API
|
|
53
|
+
|
|
54
|
+
| Member | Returns | Notes |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| `Rangeable()` | constructor | empty container |
|
|
57
|
+
| `r.insert(e, *, start, end)` | `Rangeable` (chainable) | raises `InvalidIntervalError` on `start > end` |
|
|
58
|
+
| `r[i]` | `Slot[E]` | `Slot.objs` is the active-set tuple |
|
|
59
|
+
| `r.get_range(e)` | `list[tuple[int, int]]` | merged disjoint ranges |
|
|
60
|
+
| `r.transitions(*, lo, hi)` | `list[TransitionEvent[E]]` | `hi=None` means +∞ |
|
|
61
|
+
| `r.count` / `len(r)` | `int` | distinct elements |
|
|
62
|
+
| `r.empty` / `bool(r)` | `bool` | |
|
|
63
|
+
| `iter(r)` | `Iterator[(E, list[(int, int)])]` | first-insert order |
|
|
64
|
+
| `r.copy()` | `Rangeable[E]` | deep copy |
|
|
65
|
+
| `r.version` | `int` | unchanged on idempotent insert |
|
|
66
|
+
|
|
67
|
+
## Semantics
|
|
68
|
+
|
|
69
|
+
- **End is inclusive**: `[a, b]` covers `a..=b`, both ends.
|
|
70
|
+
- **Same-element merging**: equal elements (by `__eq__` + `__hash__`) merge on overlap or integer adjacency. `[2, 4] ∪ [5, 7] = [2, 7]`.
|
|
71
|
+
- **Idempotent insert**: re-inserting a contained interval does not bump `version`.
|
|
72
|
+
- **Out-of-order rejected**: `r.insert(e, start=5, end=2)` raises `InvalidIntervalError`.
|
|
73
|
+
- **Active-set ordering**: deterministic — first-insert order of the element.
|
|
74
|
+
- **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.
|
|
75
|
+
|
|
76
|
+
See [RangeableRFC](https://github.com/ZhgChgLi/RangeableRFC) § 4 for normative semantics and § 10 for the 23-case test contract.
|
|
77
|
+
|
|
78
|
+
## Cross-language consistency
|
|
79
|
+
|
|
80
|
+
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.
|
|
81
|
+
|
|
82
|
+
## See also
|
|
83
|
+
|
|
84
|
+
- **[RangeableRFC](https://github.com/ZhgChgLi/RangeableRFC)** — normative specification.
|
|
85
|
+
- **[RubyRangeable](https://github.com/ZhgChgLi/RubyRangeable)** — sibling Ruby reference implementation, published as the `rangeable` gem.
|
|
86
|
+
- **[SwiftRangeable](https://github.com/ZhgChgLi/SwiftRangeable)** — sibling Swift reference implementation.
|
|
87
|
+
- **[JSRangeable](https://github.com/ZhgChgLi/JSRangeable)** — sibling TypeScript reference implementation, published as the `rangeable-js` npm package.
|
|
88
|
+
|
|
89
|
+
## Development
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
python -m pip install -e ".[dev]"
|
|
93
|
+
pytest -q
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The suite covers the full RFC § 10 contract, the cross-language fixture replay, and a property test against a brute-force oracle.
|
|
97
|
+
|
|
98
|
+
## License
|
|
99
|
+
|
|
100
|
+
MIT (c) ZhgChgLi
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.18"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "rangeable"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Hashable-element interval set with first-insert ordered active queries."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "ZhgChgLi", email = "zhgchgli@gmail.com" }]
|
|
13
|
+
keywords = ["interval", "range", "merge", "boundary-events", "rfc"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 5 - Production/Stable",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Topic :: Software Development :: Libraries",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://github.com/ZhgChgLi/PythonRangeable"
|
|
30
|
+
Source = "https://github.com/ZhgChgLi/PythonRangeable"
|
|
31
|
+
Documentation = "https://github.com/ZhgChgLi/RangeableRFC/blob/main/RFC.md"
|
|
32
|
+
Issues = "https://github.com/ZhgChgLi/PythonRangeable/issues"
|
|
33
|
+
Changelog = "https://github.com/ZhgChgLi/PythonRangeable/blob/main/CHANGELOG.md"
|
|
34
|
+
|
|
35
|
+
[project.optional-dependencies]
|
|
36
|
+
dev = ["pytest>=8", "build>=1.2", "twine>=5"]
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["src/rangeable"]
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.sdist]
|
|
42
|
+
include = [
|
|
43
|
+
"src/rangeable/**/*.py",
|
|
44
|
+
"src/rangeable/py.typed",
|
|
45
|
+
"README.md",
|
|
46
|
+
"CHANGELOG.md",
|
|
47
|
+
"LICENSE",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
[tool.pytest.ini_options]
|
|
51
|
+
testpaths = ["tests"]
|
|
@@ -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)
|
|
@@ -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
|
|
@@ -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
|
+
"""
|
|
@@ -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)
|
|
@@ -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
|
|
File without changes
|