intranges 0.2.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.
Files changed (34) hide show
  1. intranges-0.2.0/.gitattributes +1 -0
  2. intranges-0.2.0/.github/FUNDING.yml +1 -0
  3. intranges-0.2.0/.gitignore +12 -0
  4. intranges-0.2.0/CHANGELOG.md +49 -0
  5. intranges-0.2.0/CONTRIBUTING.md +19 -0
  6. intranges-0.2.0/IMPROVEMENTS-round.md +44 -0
  7. intranges-0.2.0/LICENSE +21 -0
  8. intranges-0.2.0/PKG-INFO +173 -0
  9. intranges-0.2.0/README.md +144 -0
  10. intranges-0.2.0/docs/GUIDE.md +148 -0
  11. intranges-0.2.0/pyproject.toml +56 -0
  12. intranges-0.2.0/requirements-dev.txt +3 -0
  13. intranges-0.2.0/scripts/check.ps1 +7 -0
  14. intranges-0.2.0/scripts/release.ps1 +28 -0
  15. intranges-0.2.0/src/intranges/__init__.py +50 -0
  16. intranges-0.2.0/src/intranges/_cli.py +174 -0
  17. intranges-0.2.0/src/intranges/_compact.py +72 -0
  18. intranges-0.2.0/src/intranges/_dialect.py +108 -0
  19. intranges-0.2.0/src/intranges/_errors.py +67 -0
  20. intranges-0.2.0/src/intranges/_intranges.py +199 -0
  21. intranges-0.2.0/src/intranges/_parse.py +189 -0
  22. intranges-0.2.0/src/intranges/_spans.py +81 -0
  23. intranges-0.2.0/src/intranges/_tokens.py +180 -0
  24. intranges-0.2.0/src/intranges/py.typed +0 -0
  25. intranges-0.2.0/tests/test_cli.py +103 -0
  26. intranges-0.2.0/tests/test_compact.py +44 -0
  27. intranges-0.2.0/tests/test_contract.py +178 -0
  28. intranges-0.2.0/tests/test_dialects.py +148 -0
  29. intranges-0.2.0/tests/test_e2e.py +77 -0
  30. intranges-0.2.0/tests/test_errors_and_policies.py +165 -0
  31. intranges-0.2.0/tests/test_intranges.py +134 -0
  32. intranges-0.2.0/tests/test_package.py +30 -0
  33. intranges-0.2.0/tests/test_parse.py +70 -0
  34. intranges-0.2.0/tests/test_property.py +136 -0
@@ -0,0 +1 @@
1
+ * text=auto eol=lf
@@ -0,0 +1 @@
1
+ buy_me_a_coffee: jlianesglrs
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ build/
6
+ dist/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .coverage
12
+ htmlcov/
@@ -0,0 +1,49 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.2.0]
10
+
11
+ ### Added
12
+
13
+ - `IntRanges`, an immutable `collections.abc.Set[int]` stored as spans. Membership,
14
+ `count`, iteration, union, intersection, difference, symmetric difference, subset
15
+ tests and `complement(lower, upper)` never expand values.
16
+ - `parse_ranges`, `count` and `normalize` read strings into spans, with
17
+ `on_overlap="merge" | "reject"` and `on_unsorted="allow" | "reject"` policies.
18
+ - `Dialect` and the presets `DEFAULT`, `PAGES`, `CPULIST`, `SLURM` and `HTTP`, plus the
19
+ `DIALECTS` registry: kernel `:used/group` and taskset `:N` strides, `N` and `all`,
20
+ Slurm steps and bracketed hostlist numbers, RFC 9110 open ends, suffix ranges and
21
+ clamping.
22
+ - Open-ended ranges such as `5-` when `upper=` is given; `lower=` and `upper=` bounds.
23
+ - Typed errors `RangeSyntaxError`, `DescendingRangeError`, `RangeOverlapError`,
24
+ `RangeOrderError`, `RangeBoundsError` and `RangeSizeError`. `RangeError` now carries
25
+ `text`, `token` and `offset` and offers `pointer()`.
26
+ - `parse` gains `dialect`, `lower`, `upper` and `max_size`; `compact` gains `dialect` and
27
+ `width`, and writes an `IntRanges` or a step-1 `range` without expanding it.
28
+ - `DEFAULT_MAX_SIZE` and `DEFAULT_MAX_SPANS`.
29
+ - The `intranges` command with `expand`, `compress`, `normalize`, `count`, `contains`
30
+ and `complement`.
31
+ - `docs/GUIDE.md`.
32
+
33
+ ### Changed
34
+
35
+ - `parse` refuses to build more than 10,000,000 values unless `max_size` is raised or
36
+ set to `None`, so a string like `0-10000000000` fails fast instead of exhausting
37
+ memory.
38
+ - `compact` now rejects values that are not integers (such as floats) with `TypeError`
39
+ instead of writing them as text.
40
+ - Separators containing digits raise `ValueError`, since they make tokens ambiguous.
41
+
42
+ ## [0.1.0]
43
+
44
+ - First release.
45
+ - `parse` expands a compact range string into a list of ints, with support for single
46
+ values, ranges, negatives, whitespace and configurable separators.
47
+ - `compact` collapses an iterable of ints into a range string, sorting and de-duplicating
48
+ the input first.
49
+ - `RangeError` is raised for malformed or descending tokens.
@@ -0,0 +1,19 @@
1
+ # Contributing
2
+
3
+ Thanks for taking the time. This project keeps a small, predictable shape.
4
+
5
+ ## Setup
6
+
7
+ ```
8
+ pip install -r requirements-dev.txt
9
+ ```
10
+
11
+ ## Before you open a pull request
12
+
13
+ - Keep public API in `src/intranges/__init__.py`; internals live in `_`-prefixed modules.
14
+ - Add a test for the behaviour you change. Tests inject time, disk and network; they
15
+ never touch the real thing.
16
+ - Run `scripts/check.ps1` (format, lint, types, tests) and make sure it passes.
17
+ - One logical change per commit, imperative subject line.
18
+
19
+ Open an issue first if the change is large or shifts the public API.
@@ -0,0 +1,44 @@
1
+ # Improvement round: ranked plan
2
+
3
+ Ranked by user value. Every item is additive: `parse`, `compact` and `RangeError` keep
4
+ their signatures and their documented behaviour.
5
+
6
+ 1. **Lazy `IntRanges` set type.** An immutable `collections.abc.Set[int]` stored as sorted,
7
+ disjoint, inclusive spans. Membership is a binary search, `count` is arithmetic, and
8
+ iteration is streamed, so `"0-10000000000"` costs two integers. Union, intersection,
9
+ difference, symmetric difference, subset tests and `complement(lower, upper)` work on
10
+ spans and never expand values.
11
+ 2. **Size guards.** `parse` refuses to build a list larger than `max_size` values
12
+ (default 10,000,000) and checks the total before allocating anything. Strided input
13
+ that would create more than `max_spans` spans raises too. Both raise
14
+ `RangeSizeError`.
15
+ 3. **Typed errors that point at the bad token.** `RangeError` gains `text`, `token` and
16
+ `offset` attributes, and specific subclasses: `RangeSyntaxError`,
17
+ `DescendingRangeError`, `RangeOverlapError`, `RangeOrderError`, `RangeBoundsError`,
18
+ `RangeSizeError`.
19
+ All stay `ValueError` subclasses.
20
+ 4. **Named dialects grounded in documented syntaxes.** A frozen `Dialect` dataclass plus
21
+ presets: `DEFAULT`, `PAGES` (CUPS `page-ranges`), `CPULIST` (Linux kernel cpu lists
22
+ with `:used/group`, util-linux `taskset` stride `:N`, `N` for the last CPU and `all`),
23
+ `SLURM` (sbatch `--array` step `:N`, optional hostlist brackets) and `HTTP`
24
+ (RFC 9110 byte ranges, open ends, suffix ranges, clamping). Custom dialects are a
25
+ `Dialect(...)` call away.
26
+ 5. **Unambiguous negative-number rule.** A `-` directly before a digit at the start of a
27
+ bound is a sign; the separator is the first `-` after the first bound. Dialects that
28
+ disallow negatives reject `-3` with a clear message instead of guessing.
29
+ 6. **Open-ended ranges with explicit bounds.** `"5-"` means "5 to the upper bound" and is
30
+ only accepted when the caller supplies `upper=`. Without it the error says so.
31
+ 7. **Overlap and order policy.** `parse_ranges(..., on_overlap="merge" | "reject",
32
+ on_unsorted="allow" | "reject")`. Rejection names the offending token.
33
+ 8. **Canonical compression and round trip.** `normalize(text)` returns the canonical
34
+ string; `compact` accepts an `IntRanges` without expanding it, a `dialect` and a
35
+ zero-pad `width`. `parse_ranges(compact(x)) == IntRanges(x)` for every finite set.
36
+ 9. **`count(text)` without materialising** and bound checks via `lower=` / `upper=`.
37
+ 10. **CLI** `intranges` with `expand`, `compress`, `normalize`, `count`, `contains` and
38
+ `complement`, dialect and bound options, runnable `--help` examples, errors on stderr
39
+ with exit code 1.
40
+ 11. **Docs.** Docstrings on every public name, a `docs/GUIDE.md` covering grammar,
41
+ dialects, policies and recipes, and README sources for every dialect.
42
+ 12. **Contracts and end-to-end tests.** A contract test over every public name, a
43
+ seeded property test against a brute-force `set` model, and a real wheel install
44
+ driving the console script.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Javier Lianes
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.
@@ -0,0 +1,173 @@
1
+ Metadata-Version: 2.5
2
+ Name: intranges
3
+ Version: 0.2.0
4
+ Summary: Typed conversion between range strings like 1-3,5 and ints, with a lazy set type, dialects and a CLI
5
+ Project-URL: Homepage, https://lianes8studio.com
6
+ Project-URL: Repository, https://github.com/JaviLianes8/intranges
7
+ Project-URL: Issues, https://github.com/JaviLianes8/intranges/issues
8
+ Project-URL: Changelog, https://github.com/JaviLianes8/intranges/blob/main/CHANGELOG.md
9
+ Project-URL: LinkedIn, https://www.linkedin.com/in/jlianes/
10
+ Project-URL: Funding, https://buymeacoffee.com/jlianesglrs
11
+ Author-email: Javier Lianes <javier@lianes8studio.com>
12
+ License-Expression: MIT
13
+ License-File: LICENSE
14
+ Keywords: cli,compact,cpulist,integers,intervals,page-ranges,parse,range-string,ranges
15
+ Classifier: Development Status :: 4 - Beta
16
+ Classifier: Environment :: Console
17
+ Classifier: Intended Audience :: Developers
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Topic :: Utilities
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.10
22
+ Provides-Extra: dev
23
+ Requires-Dist: build; extra == 'dev'
24
+ Requires-Dist: mypy; extra == 'dev'
25
+ Requires-Dist: pytest; extra == 'dev'
26
+ Requires-Dist: ruff; extra == 'dev'
27
+ Requires-Dist: twine; extra == 'dev'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # intranges
31
+
32
+ Typed, zero-dependency conversion between range strings like `1-3,5,7-9` and integers,
33
+ with a lazy set type that never expands huge ranges, named dialects for real-world
34
+ syntaxes, typed errors that point at the bad token, and a command-line tool.
35
+
36
+ ## Install
37
+
38
+ ```
39
+ pip install intranges
40
+ ```
41
+
42
+ ## Usage
43
+
44
+ ```python
45
+ from intranges import compact, parse
46
+
47
+ parse("1-5,8,10-12")
48
+ # [1, 2, 3, 4, 5, 8, 10, 11, 12]
49
+
50
+ compact([6, 5, 1, 1, 3, 2])
51
+ # "1-3,5-6"
52
+
53
+ parse("-3--1, 0, 2")
54
+ # [-3, -2, -1, 0, 2]
55
+
56
+ parse("1..3;6", range_sep="..", list_sep=";")
57
+ # [1, 2, 3, 6]
58
+ ```
59
+
60
+ `parse` keeps input order and repeats. It refuses to build more than 10,000,000 values
61
+ by default (`max_size=`), and it checks that before allocating anything.
62
+
63
+ ### A set that never expands
64
+
65
+ ```python
66
+ from intranges import IntRanges, parse_ranges, count, normalize
67
+
68
+ r = parse_ranges("0-10000000000,5") # two spans, not ten billion ints
69
+ r.count # 10000000001
70
+ 7_000_000_000 in r # True, binary search
71
+ r.spans # ((0, 10000000000),)
72
+
73
+ free = IntRanges.parse("0-3,8-11").complement(0, 15)
74
+ str(free) # "4-7,12-15"
75
+
76
+ a, b = IntRanges.parse("1-10"), IntRanges.parse("5-15")
77
+ str(a & b), str(a - b), str(a ^ b) # ("5-10", "1-4", "1-4,11-15")
78
+
79
+ count("1-1000000,500000-2000000") # 2000000
80
+ normalize("8, 1-3, 2, 4") # "1-4,8"
81
+ ```
82
+
83
+ `IntRanges` is an immutable `collections.abc.Set[int]`: `in`, iteration (ascending and
84
+ `reversed`), `len`, `|`, `&`, `-`, `^`, subset tests, `first`, `last`, `complement`,
85
+ hashing and pickling all work on spans.
86
+
87
+ ### Dialects
88
+
89
+ | Dialect | Example | Source |
90
+ |-----------|--------------------------------|--------|
91
+ | `DEFAULT` | `-3--1,5,9-` (with `upper=`) | this library |
92
+ | `PAGES` | `1-4,7,9-12` | CUPS `-o page-ranges=1-4,7,9-12`, [Command-Line Printing and Options](https://www.cups.org/doc/options.html) |
93
+ | `CPULIST` | `0-3,8-11`, `100-2000:2/25`, `16-N`, `all` | Linux kernel, [kernel-parameters: CPU lists](https://docs.kernel.org/admin-guide/kernel-parameters.html) |
94
+ | `CPULIST` | `0-10:3` (stride) | util-linux [taskset(1)](https://man7.org/linux/man-pages/man1/taskset.1.html): "0-10:3 is interpreted as 0,3,6,9" |
95
+ | `SLURM` | `0-15:4`, `[1-3,5]`, `001-010` | [sbatch `--array`](https://slurm.schedmd.com/sbatch.html), hostlist numbers as in `tux[1-3]` from [scontrol](https://slurm.schedmd.com/scontrol.html) |
96
+ | `HTTP` | `bytes=0-499,9500-`, `-500` | [RFC 9110 section 14.1.2, Byte Ranges](https://www.rfc-editor.org/rfc/rfc9110#section-14.1.2) |
97
+
98
+ ```python
99
+ from intranges import CPULIST, HTTP, SLURM, parse, parse_ranges, compact
100
+
101
+ parse("100-200:2/25", dialect=CPULIST) # [100, 101, 125, 126, 150, 151, 175, 176, 200]
102
+ parse("16-N", dialect=CPULIST, upper=19) # [16, 17, 18, 19]
103
+ parse("0-15:4", dialect=SLURM) # [0, 4, 8, 12]
104
+ parse_ranges("bytes=-500", dialect=HTTP, upper=9999).spans # ((9500, 9999),)
105
+ compact(range(1, 11), dialect=SLURM, width=3) # "001-010"
106
+ ```
107
+
108
+ Open ends such as `"5-"` are accepted only when you pass `upper=`. Unsupported parts of
109
+ a source syntax are rejected rather than guessed, for example Slurm's `%` throttle
110
+ suffix. The HTTP dialect clamps ends past `upper` as RFC 9110 describes; `upper` is the
111
+ representation length minus one.
112
+
113
+ ### Errors
114
+
115
+ Every error is a `RangeError`, a `ValueError` subclass, carrying `text`, `token` and
116
+ `offset`:
117
+
118
+ ```python
119
+ from intranges import RangeError, parse
120
+
121
+ try:
122
+ parse("1-3, 5, x-9")
123
+ except RangeError as err:
124
+ print(err)
125
+ print(err.pointer())
126
+ ```
127
+
128
+ prints
129
+
130
+ ```text
131
+ invalid range token 'x-9' for the default dialect at offset 8
132
+ 1-3, 5, x-9
133
+ ^^^
134
+ ```
135
+
136
+ Subclasses: `RangeSyntaxError`, `DescendingRangeError`, `RangeOverlapError`,
137
+ `RangeOrderError`, `RangeBoundsError`, `RangeSizeError`. Overlapping and unsorted tokens
138
+ are merged by default; pass `on_overlap="reject"` or `on_unsorted="reject"` to
139
+ `parse_ranges` to refuse them.
140
+
141
+ ## Command line
142
+
143
+ ```
144
+ intranges expand "1-3,7" # 1 2 3 7, one per line
145
+ intranges expand "0-10:3" --dialect cpulist -d , # 0,3,6,9
146
+ seq 1 5 | intranges compress # 1-5
147
+ intranges normalize "8, 1-3, 2, 4" # 1-4,8
148
+ intranges count "0-10000000000" # 10000000001
149
+ intranges contains "0-3,8-11" 9 12 # 9 yes, 12 no; exit code 2
150
+ intranges complement "0-3,8-11" --lower 0 --upper 15 # 4-7,12-15
151
+ ```
152
+
153
+ Errors go to stderr with exit code 1. Put `--` before a range string that starts with
154
+ `-`. See `intranges --help` and [the guide](docs/GUIDE.md) for everything else.
155
+
156
+ ## Why
157
+
158
+ Expressing a set of integers as a compact string like `1-5,8,10-12` is common in page
159
+ selectors, CPU affinity, job arrays and config files, but converting it back and forth
160
+ usually ends up reimplemented in each project. `intspan` covers the idea with a
161
+ set-like class, yet it is untyped (it ships no `py.typed`) and expands its ranges in
162
+ memory. `intranges` is strictly typed, keeps ranges as spans, and follows documented
163
+ syntaxes instead of inventing new ones.
164
+
165
+ ## License
166
+
167
+ MIT. See [LICENSE](LICENSE).
168
+
169
+ ## Links
170
+
171
+ - Website: https://lianes8studio.com
172
+ - GitHub: https://github.com/JaviLianes8
173
+ - LinkedIn: https://www.linkedin.com/in/jlianes/
@@ -0,0 +1,144 @@
1
+ # intranges
2
+
3
+ Typed, zero-dependency conversion between range strings like `1-3,5,7-9` and integers,
4
+ with a lazy set type that never expands huge ranges, named dialects for real-world
5
+ syntaxes, typed errors that point at the bad token, and a command-line tool.
6
+
7
+ ## Install
8
+
9
+ ```
10
+ pip install intranges
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```python
16
+ from intranges import compact, parse
17
+
18
+ parse("1-5,8,10-12")
19
+ # [1, 2, 3, 4, 5, 8, 10, 11, 12]
20
+
21
+ compact([6, 5, 1, 1, 3, 2])
22
+ # "1-3,5-6"
23
+
24
+ parse("-3--1, 0, 2")
25
+ # [-3, -2, -1, 0, 2]
26
+
27
+ parse("1..3;6", range_sep="..", list_sep=";")
28
+ # [1, 2, 3, 6]
29
+ ```
30
+
31
+ `parse` keeps input order and repeats. It refuses to build more than 10,000,000 values
32
+ by default (`max_size=`), and it checks that before allocating anything.
33
+
34
+ ### A set that never expands
35
+
36
+ ```python
37
+ from intranges import IntRanges, parse_ranges, count, normalize
38
+
39
+ r = parse_ranges("0-10000000000,5") # two spans, not ten billion ints
40
+ r.count # 10000000001
41
+ 7_000_000_000 in r # True, binary search
42
+ r.spans # ((0, 10000000000),)
43
+
44
+ free = IntRanges.parse("0-3,8-11").complement(0, 15)
45
+ str(free) # "4-7,12-15"
46
+
47
+ a, b = IntRanges.parse("1-10"), IntRanges.parse("5-15")
48
+ str(a & b), str(a - b), str(a ^ b) # ("5-10", "1-4", "1-4,11-15")
49
+
50
+ count("1-1000000,500000-2000000") # 2000000
51
+ normalize("8, 1-3, 2, 4") # "1-4,8"
52
+ ```
53
+
54
+ `IntRanges` is an immutable `collections.abc.Set[int]`: `in`, iteration (ascending and
55
+ `reversed`), `len`, `|`, `&`, `-`, `^`, subset tests, `first`, `last`, `complement`,
56
+ hashing and pickling all work on spans.
57
+
58
+ ### Dialects
59
+
60
+ | Dialect | Example | Source |
61
+ |-----------|--------------------------------|--------|
62
+ | `DEFAULT` | `-3--1,5,9-` (with `upper=`) | this library |
63
+ | `PAGES` | `1-4,7,9-12` | CUPS `-o page-ranges=1-4,7,9-12`, [Command-Line Printing and Options](https://www.cups.org/doc/options.html) |
64
+ | `CPULIST` | `0-3,8-11`, `100-2000:2/25`, `16-N`, `all` | Linux kernel, [kernel-parameters: CPU lists](https://docs.kernel.org/admin-guide/kernel-parameters.html) |
65
+ | `CPULIST` | `0-10:3` (stride) | util-linux [taskset(1)](https://man7.org/linux/man-pages/man1/taskset.1.html): "0-10:3 is interpreted as 0,3,6,9" |
66
+ | `SLURM` | `0-15:4`, `[1-3,5]`, `001-010` | [sbatch `--array`](https://slurm.schedmd.com/sbatch.html), hostlist numbers as in `tux[1-3]` from [scontrol](https://slurm.schedmd.com/scontrol.html) |
67
+ | `HTTP` | `bytes=0-499,9500-`, `-500` | [RFC 9110 section 14.1.2, Byte Ranges](https://www.rfc-editor.org/rfc/rfc9110#section-14.1.2) |
68
+
69
+ ```python
70
+ from intranges import CPULIST, HTTP, SLURM, parse, parse_ranges, compact
71
+
72
+ parse("100-200:2/25", dialect=CPULIST) # [100, 101, 125, 126, 150, 151, 175, 176, 200]
73
+ parse("16-N", dialect=CPULIST, upper=19) # [16, 17, 18, 19]
74
+ parse("0-15:4", dialect=SLURM) # [0, 4, 8, 12]
75
+ parse_ranges("bytes=-500", dialect=HTTP, upper=9999).spans # ((9500, 9999),)
76
+ compact(range(1, 11), dialect=SLURM, width=3) # "001-010"
77
+ ```
78
+
79
+ Open ends such as `"5-"` are accepted only when you pass `upper=`. Unsupported parts of
80
+ a source syntax are rejected rather than guessed, for example Slurm's `%` throttle
81
+ suffix. The HTTP dialect clamps ends past `upper` as RFC 9110 describes; `upper` is the
82
+ representation length minus one.
83
+
84
+ ### Errors
85
+
86
+ Every error is a `RangeError`, a `ValueError` subclass, carrying `text`, `token` and
87
+ `offset`:
88
+
89
+ ```python
90
+ from intranges import RangeError, parse
91
+
92
+ try:
93
+ parse("1-3, 5, x-9")
94
+ except RangeError as err:
95
+ print(err)
96
+ print(err.pointer())
97
+ ```
98
+
99
+ prints
100
+
101
+ ```text
102
+ invalid range token 'x-9' for the default dialect at offset 8
103
+ 1-3, 5, x-9
104
+ ^^^
105
+ ```
106
+
107
+ Subclasses: `RangeSyntaxError`, `DescendingRangeError`, `RangeOverlapError`,
108
+ `RangeOrderError`, `RangeBoundsError`, `RangeSizeError`. Overlapping and unsorted tokens
109
+ are merged by default; pass `on_overlap="reject"` or `on_unsorted="reject"` to
110
+ `parse_ranges` to refuse them.
111
+
112
+ ## Command line
113
+
114
+ ```
115
+ intranges expand "1-3,7" # 1 2 3 7, one per line
116
+ intranges expand "0-10:3" --dialect cpulist -d , # 0,3,6,9
117
+ seq 1 5 | intranges compress # 1-5
118
+ intranges normalize "8, 1-3, 2, 4" # 1-4,8
119
+ intranges count "0-10000000000" # 10000000001
120
+ intranges contains "0-3,8-11" 9 12 # 9 yes, 12 no; exit code 2
121
+ intranges complement "0-3,8-11" --lower 0 --upper 15 # 4-7,12-15
122
+ ```
123
+
124
+ Errors go to stderr with exit code 1. Put `--` before a range string that starts with
125
+ `-`. See `intranges --help` and [the guide](docs/GUIDE.md) for everything else.
126
+
127
+ ## Why
128
+
129
+ Expressing a set of integers as a compact string like `1-5,8,10-12` is common in page
130
+ selectors, CPU affinity, job arrays and config files, but converting it back and forth
131
+ usually ends up reimplemented in each project. `intspan` covers the idea with a
132
+ set-like class, yet it is untyped (it ships no `py.typed`) and expands its ranges in
133
+ memory. `intranges` is strictly typed, keeps ranges as spans, and follows documented
134
+ syntaxes instead of inventing new ones.
135
+
136
+ ## License
137
+
138
+ MIT. See [LICENSE](LICENSE).
139
+
140
+ ## Links
141
+
142
+ - Website: https://lianes8studio.com
143
+ - GitHub: https://github.com/JaviLianes8
144
+ - LinkedIn: https://www.linkedin.com/in/jlianes/
@@ -0,0 +1,148 @@
1
+ # intranges guide
2
+
3
+ This guide covers the grammar, the dialects, the policies and a few recipes. The README
4
+ has the short version.
5
+
6
+ ## Grammar
7
+
8
+ A range string is a list of tokens joined by `list_sep` (default `,`). Whitespace
9
+ around tokens and around separators is ignored. An empty or blank string is the empty
10
+ set. An empty token, as in `1,,2` or a trailing `1,2,`, is a `RangeSyntaxError`.
11
+
12
+ | Token | Meaning | Needs |
13
+ |---------------|--------------------------------------------|-----------------------------|
14
+ | `7` | the single value 7 | |
15
+ | `1-5` | 1 to 5 inclusive | |
16
+ | `5-` | 5 to `upper` | `upper=`, `open_end` |
17
+ | `-5` | the last 5 values up to `upper` (HTTP) | `upper=`, `suffix` |
18
+ | `0-15:4` | every 4th value: 0, 4, 8, 12 | `stride` |
19
+ | `0-99:2/25` | 2 values from the start of every group of 25 | `stride`, `groups` |
20
+ | `N` | `upper` (kernel cpu lists) | `upper=`, `last_alias` |
21
+ | `all` | `lower` (or the dialect minimum) to `upper` | `upper=`, `all_alias` |
22
+
23
+ A range must run low to high: `5-1` is a `DescendingRangeError`.
24
+
25
+ ### Negative numbers and the dash
26
+
27
+ When a dialect allows negatives (only `DEFAULT` among the presets), a `-` that starts a
28
+ bound and is followed by a digit is a sign. The range separator is the first `-` after
29
+ the first bound. So `-3--1` is -3 to -1, `-2-2` is -2 to 2, `-5` is the single value -5
30
+ and `5-` is an open end. `1--3` reads as 1 to -3 and is rejected as descending. `--3`
31
+ and `+3` are syntax errors. Dialects without negatives reject `-3` instead of guessing,
32
+ except `HTTP`, where a leading `-` is a suffix range as RFC 9110 defines it.
33
+
34
+ ### Bounds
35
+
36
+ `lower=` and `upper=` restrict every value and supply the meaning of open ends, suffix
37
+ ranges, `N` and `all`. A value outside them is a `RangeBoundsError`. A dialect may also
38
+ set a `minimum` (1 for pages, 0 for cpu lists, Slurm indexes and bytes). When both are
39
+ given the stricter floor wins. `lower > upper` is a plain `ValueError`.
40
+
41
+ ## Dialects
42
+
43
+ Each preset follows a documented syntax. Anything outside it is rejected.
44
+
45
+ - `PAGES`: CUPS `-o page-ranges=1-4,7,9-12`. Pages start at 1. CUPS prints pages in
46
+ ascending order whatever the input order, which matches `parse_ranges`. The open
47
+ forms `-4` and `9-` are not part of this preset; build a `Dialect` with
48
+ `open_end=True` and pass `upper=` when you need `9-`.
49
+ - `CPULIST`: the Linux kernel cpu list format, `0-3,8-11`, with the kernel's
50
+ `a-b:used/group` form (`100-2000:2/25` is 100, 101, 125, 126, ...), `N` for the last
51
+ CPU and `all`. It also accepts the util-linux `taskset` stride `a-b:N`, which equals
52
+ `a-b:1/N`.
53
+ - `SLURM`: `sbatch --array` indexes such as `0,6,16-32` and the step form `0-15:4`.
54
+ The numeric part of a hostlist may be wrapped in brackets (`[1-3,5]`) and zero-padded
55
+ (`001-010`). The `%` throttle suffix is not part of the index set and is rejected.
56
+ - `HTTP`: RFC 9110 byte ranges. The `bytes=` unit is optional on input and written on
57
+ output. `500-` runs to `upper`, `-500` is the last 500 bytes, and ends past `upper` are
58
+ clamped. A start past `upper` is unsatisfiable and raises `RangeBoundsError`. A user
59
+ `lower=` is a hard bound, not a clamp target. Pass `upper=length - 1`.
60
+
61
+ Build your own with `Dialect(...)`; incoherent settings, such as two equal separators
62
+ or suffix ranges together with negatives, raise `ValueError` at construction.
63
+
64
+ ```python
65
+ from intranges import Dialect, parse
66
+
67
+ dots = Dialect(name="dots", range_sep="..", list_sep=";", stride="by")
68
+ parse("1..9by4;20", dialect=dots) # [1, 5, 9, 20]
69
+ ```
70
+
71
+ ## Lists versus sets
72
+
73
+ | Function | Returns | Order and repeats | Memory |
74
+ |-----------------|-------------|-------------------------|----------------------------|
75
+ | `parse` | `list[int]` | input order, repeats kept | one int per value, capped by `max_size` |
76
+ | `parse_ranges` | `IntRanges` | sorted, merged | one pair per span |
77
+ | `count` | `int` | distinct values | one pair per span |
78
+ | `normalize` | `str` | canonical | one pair per span |
79
+
80
+ Strides create one span per selected group, so `parse_ranges` caps them with
81
+ `max_spans` (default 1,000,000).
82
+
83
+ ## Policies
84
+
85
+ `parse_ranges` merges overlapping, repeated and unsorted tokens by default.
86
+
87
+ - `on_overlap="reject"` raises `RangeOverlapError` naming the later of two overlapping
88
+ tokens. Adjacent tokens such as `1-2,3-4` do not overlap.
89
+ - `on_unsorted="reject"` raises `RangeOrderError` when a token starts below the start of
90
+ the token before it.
91
+
92
+ ## Canonical form
93
+
94
+ `compact` and `IntRanges.format` write the canonical string: ascending, merged, with a
95
+ run of two or more consecutive values written as `low-high` and single values alone.
96
+ For any finite set of ints, `parse_ranges(compact(values)) == IntRanges(values)` and
97
+ `normalize(compact(values)) == compact(values)`.
98
+
99
+ ## Errors
100
+
101
+ All errors subclass `RangeError`, which subclasses `ValueError`. Parse errors carry
102
+ `text`, `token` and `offset`, and `err.pointer()` draws a caret line under the token.
103
+
104
+ ## Recipes
105
+
106
+ Free CPUs on a 16-CPU machine:
107
+
108
+ ```python
109
+ from intranges import CPULIST, parse_ranges
110
+
111
+ busy = parse_ranges("0-3,8-11", dialect=CPULIST)
112
+ busy.complement(0, 15).format(CPULIST) # "4-7,12-15"
113
+ ```
114
+
115
+ Validate a user page selection for a 12-page document:
116
+
117
+ ```python
118
+ from intranges import PAGES, RangeError, parse_ranges
119
+
120
+ try:
121
+ pages = parse_ranges(user_input, dialect=PAGES, upper=12)
122
+ except RangeError as err:
123
+ show_error(str(err), err.pointer())
124
+ ```
125
+
126
+ Merge byte ranges of a request against a 10,000-byte file:
127
+
128
+ ```python
129
+ from intranges import HTTP, parse_ranges
130
+
131
+ parse_ranges("bytes=0-99,50-199,-100", dialect=HTTP, upper=9999).spans
132
+ # ((0, 199), (9900, 9999))
133
+ ```
134
+
135
+ ## Command line
136
+
137
+ ```
138
+ intranges expand TEXT [-d DELIM] [--unique] [--max-size N] # N=0 lifts the cap
139
+ intranges compress [VALUES ...] [--width W] # values from stdin when omitted;
140
+ # --lower/--upper validate them
141
+ intranges normalize TEXT [--to DIALECT] [--width W]
142
+ intranges count TEXT
143
+ intranges contains TEXT VALUE [VALUE ...] # exit 2 if any value is absent
144
+ intranges complement TEXT --lower L --upper U
145
+ ```
146
+
147
+ Every command takes `--dialect`, `--lower`, `--upper`, `--range-sep` and `--list-sep`.
148
+ `TEXT` may be `-` to read from stdin. Errors go to stderr with exit code 1.
@@ -0,0 +1,56 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "intranges"
7
+ version = "0.2.0"
8
+ description = "Typed conversion between range strings like 1-3,5 and ints, with a lazy set type, dialects and a CLI"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Javier Lianes", email = "javier@lianes8studio.com" }]
14
+ keywords = ["ranges", "integers", "parse", "compact", "range-string", "cpulist", "page-ranges", "intervals", "cli"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Typing :: Typed",
20
+ "Environment :: Console",
21
+ "Topic :: Utilities",
22
+ ]
23
+ dependencies = []
24
+
25
+ [project.scripts]
26
+ intranges = "intranges._cli:main"
27
+
28
+ [project.optional-dependencies]
29
+ dev = ["ruff", "mypy", "pytest", "build", "twine"]
30
+
31
+ [project.urls]
32
+ Homepage = "https://lianes8studio.com"
33
+ Repository = "https://github.com/JaviLianes8/intranges"
34
+ Issues = "https://github.com/JaviLianes8/intranges/issues"
35
+ Changelog = "https://github.com/JaviLianes8/intranges/blob/main/CHANGELOG.md"
36
+ LinkedIn = "https://www.linkedin.com/in/jlianes/"
37
+ Funding = "https://buymeacoffee.com/jlianesglrs"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/intranges"]
41
+
42
+ [tool.ruff]
43
+ line-length = 100
44
+ target-version = "py310"
45
+
46
+ [tool.ruff.lint]
47
+ select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
48
+
49
+ [tool.mypy]
50
+ python_version = "3.10"
51
+ strict = true
52
+
53
+ [tool.pytest.ini_options]
54
+ testpaths = ["tests"]
55
+ pythonpath = ["src"]
56
+ addopts = "-q"