intranges 0.2.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.
- intranges/__init__.py +50 -0
- intranges/_cli.py +174 -0
- intranges/_compact.py +72 -0
- intranges/_dialect.py +108 -0
- intranges/_errors.py +67 -0
- intranges/_intranges.py +199 -0
- intranges/_parse.py +189 -0
- intranges/_spans.py +81 -0
- intranges/_tokens.py +180 -0
- intranges/py.typed +0 -0
- intranges-0.2.0.dist-info/METADATA +173 -0
- intranges-0.2.0.dist-info/RECORD +15 -0
- intranges-0.2.0.dist-info/WHEEL +4 -0
- intranges-0.2.0.dist-info/entry_points.txt +2 -0
- intranges-0.2.0.dist-info/licenses/LICENSE +21 -0
intranges/__init__.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Typed two-way conversion between compact range strings and integers.
|
|
2
|
+
|
|
3
|
+
``parse`` and ``compact`` convert between strings like ``"1-3,5,7-9"`` and lists of
|
|
4
|
+
ints. ``IntRanges`` is a lazy, immutable set that never expands its ranges, and
|
|
5
|
+
``parse_ranges``, ``count`` and ``normalize`` read strings into it. Named dialects cover
|
|
6
|
+
printer page lists, Linux cpu lists, Slurm indexes and HTTP byte ranges.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from ._compact import compact
|
|
12
|
+
from ._dialect import CPULIST, DEFAULT, DIALECTS, HTTP, PAGES, SLURM, Dialect
|
|
13
|
+
from ._errors import (
|
|
14
|
+
DescendingRangeError,
|
|
15
|
+
RangeBoundsError,
|
|
16
|
+
RangeError,
|
|
17
|
+
RangeOrderError,
|
|
18
|
+
RangeOverlapError,
|
|
19
|
+
RangeSizeError,
|
|
20
|
+
RangeSyntaxError,
|
|
21
|
+
)
|
|
22
|
+
from ._intranges import IntRanges
|
|
23
|
+
from ._parse import DEFAULT_MAX_SIZE, DEFAULT_MAX_SPANS, count, normalize, parse, parse_ranges
|
|
24
|
+
|
|
25
|
+
__version__ = "0.2.0"
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"CPULIST",
|
|
29
|
+
"DEFAULT",
|
|
30
|
+
"DEFAULT_MAX_SIZE",
|
|
31
|
+
"DEFAULT_MAX_SPANS",
|
|
32
|
+
"DIALECTS",
|
|
33
|
+
"HTTP",
|
|
34
|
+
"PAGES",
|
|
35
|
+
"SLURM",
|
|
36
|
+
"DescendingRangeError",
|
|
37
|
+
"Dialect",
|
|
38
|
+
"IntRanges",
|
|
39
|
+
"RangeBoundsError",
|
|
40
|
+
"RangeError",
|
|
41
|
+
"RangeOrderError",
|
|
42
|
+
"RangeOverlapError",
|
|
43
|
+
"RangeSizeError",
|
|
44
|
+
"RangeSyntaxError",
|
|
45
|
+
"compact",
|
|
46
|
+
"count",
|
|
47
|
+
"normalize",
|
|
48
|
+
"parse",
|
|
49
|
+
"parse_ranges",
|
|
50
|
+
]
|
intranges/_cli.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Command-line interface: ``intranges <command> ...``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Iterable, Sequence
|
|
9
|
+
from itertools import islice
|
|
10
|
+
from typing import NoReturn
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
from ._compact import compact
|
|
14
|
+
from ._dialect import DIALECTS, Dialect
|
|
15
|
+
from ._errors import RangeBoundsError, RangeError, RangeSizeError
|
|
16
|
+
from ._intranges import IntRanges
|
|
17
|
+
from ._parse import DEFAULT_MAX_SIZE, parse, parse_ranges
|
|
18
|
+
|
|
19
|
+
EXAMPLES = """\
|
|
20
|
+
examples:
|
|
21
|
+
intranges expand "1-3,7" # 1 2 3 7, one per line
|
|
22
|
+
intranges expand "0-10:3" --dialect cpulist -d , # 0,3,6,9
|
|
23
|
+
seq 1 5 | intranges compress # 1-5
|
|
24
|
+
intranges compress 6 5 1 3 2 # 1-3,5-6
|
|
25
|
+
intranges normalize "8, 1-3, 2, 4" # 1-4,8
|
|
26
|
+
intranges count "0-10000000000" # 10000000001
|
|
27
|
+
intranges contains "0-3,8-11" 9 12 # 9 yes / 12 no, exit 2
|
|
28
|
+
intranges complement "0-3,8-11" --lower 0 --upper 15 # 4-7,12-15
|
|
29
|
+
intranges expand "900-" --dialect http --upper 999 -d ,
|
|
30
|
+
intranges expand -- "-3--1" # put -- before a text that starts with -
|
|
31
|
+
|
|
32
|
+
exit codes: 0 success, 1 error (message on stderr), 2 a "contains" value is absent.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _Parser(argparse.ArgumentParser):
|
|
37
|
+
def error(self, message: str) -> NoReturn:
|
|
38
|
+
self.print_usage(sys.stderr)
|
|
39
|
+
self.exit(1, f"{self.prog}: error: {message}\n")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _common(sub: argparse.ArgumentParser, text: bool = True) -> None:
|
|
43
|
+
if text:
|
|
44
|
+
sub.add_argument("text", help='range string, or "-" to read it from stdin')
|
|
45
|
+
sub.add_argument("--dialect", choices=sorted(DIALECTS), default="default")
|
|
46
|
+
sub.add_argument("--lower", type=int, help="smallest allowed value")
|
|
47
|
+
sub.add_argument("--upper", type=int, help='largest allowed value; end of "5-" and N')
|
|
48
|
+
sub.add_argument("--range-sep", help="override the range separator")
|
|
49
|
+
sub.add_argument("--list-sep", help="override the list separator")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
53
|
+
"""Return the argument parser used by :func:`main`."""
|
|
54
|
+
parser = _Parser(
|
|
55
|
+
prog="intranges",
|
|
56
|
+
description="Expand, compress and query integer range strings like 1-3,5,7-9.",
|
|
57
|
+
epilog=EXAMPLES,
|
|
58
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
59
|
+
)
|
|
60
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
61
|
+
subs = parser.add_subparsers(dest="command", required=True, parser_class=_Parser)
|
|
62
|
+
sub = subs.add_parser("expand", help="print every value of a range string")
|
|
63
|
+
_common(sub)
|
|
64
|
+
sub.add_argument("-d", "--delimiter", default="\n", help="output separator (newline)")
|
|
65
|
+
sub.add_argument("--unique", action="store_true", help="sorted, without repeats")
|
|
66
|
+
sub.add_argument("--max-size", type=int, default=DEFAULT_MAX_SIZE, help="value cap; 0 lifts it")
|
|
67
|
+
sub = subs.add_parser("compress", help="turn integers into a canonical range string")
|
|
68
|
+
_common(sub, text=False)
|
|
69
|
+
sub.add_argument("values", nargs="*", help="integers; read from stdin when omitted")
|
|
70
|
+
sub.add_argument("--width", type=int, help="zero-pad numbers to this width")
|
|
71
|
+
sub = subs.add_parser("normalize", aliases=["normalise"], help="canonical form")
|
|
72
|
+
_common(sub)
|
|
73
|
+
sub.add_argument("--to", choices=sorted(DIALECTS), help="output dialect")
|
|
74
|
+
sub.add_argument("--width", type=int, help="zero-pad numbers to this width")
|
|
75
|
+
sub = subs.add_parser("count", help="number of distinct values")
|
|
76
|
+
_common(sub)
|
|
77
|
+
sub = subs.add_parser("contains", help="test membership; exit 2 if any is absent")
|
|
78
|
+
_common(sub)
|
|
79
|
+
sub.add_argument("values", nargs="+", type=int, help="integers to test")
|
|
80
|
+
sub = subs.add_parser("complement", help="values within --lower..--upper not listed")
|
|
81
|
+
_common(sub)
|
|
82
|
+
return parser
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _dialect(args: argparse.Namespace) -> Dialect:
|
|
86
|
+
return DIALECTS[args.dialect].with_separators(args.range_sep, args.list_sep)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _text(args: argparse.Namespace) -> str:
|
|
90
|
+
return sys.stdin.read().strip() if args.text == "-" else str(args.text)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _ranges(args: argparse.Namespace) -> IntRanges:
|
|
94
|
+
return parse_ranges(_text(args), dialect=_dialect(args), lower=args.lower, upper=args.upper)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _emit(values: Iterable[int], delimiter: str) -> None:
|
|
98
|
+
"""Stream values in chunks so a large expansion never builds one huge string."""
|
|
99
|
+
stream = iter(values)
|
|
100
|
+
lead = ""
|
|
101
|
+
while chunk := list(islice(stream, 4096)):
|
|
102
|
+
sys.stdout.write(lead + delimiter.join(map(str, chunk)))
|
|
103
|
+
lead = delimiter
|
|
104
|
+
sys.stdout.write("\n")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _run(args: argparse.Namespace) -> int:
|
|
108
|
+
out = sys.stdout
|
|
109
|
+
if args.command == "expand":
|
|
110
|
+
cap = args.max_size or None
|
|
111
|
+
if args.unique:
|
|
112
|
+
ranges = _ranges(args)
|
|
113
|
+
if cap is not None and ranges.count > cap:
|
|
114
|
+
raise RangeSizeError(f"expansion exceeds max_size={cap}")
|
|
115
|
+
values: Sequence[int] | IntRanges = ranges
|
|
116
|
+
else:
|
|
117
|
+
values = parse(
|
|
118
|
+
_text(args),
|
|
119
|
+
dialect=_dialect(args),
|
|
120
|
+
lower=args.lower,
|
|
121
|
+
upper=args.upper,
|
|
122
|
+
max_size=cap,
|
|
123
|
+
)
|
|
124
|
+
_emit(values, args.delimiter)
|
|
125
|
+
elif args.command == "compress":
|
|
126
|
+
raw = args.values or [t for t in re.split(r"[\s,]+", sys.stdin.read()) if t]
|
|
127
|
+
try:
|
|
128
|
+
ints = [int(token) for token in raw]
|
|
129
|
+
except ValueError as exc:
|
|
130
|
+
raise ValueError(f"not an integer: {exc}") from None
|
|
131
|
+
for value in ints:
|
|
132
|
+
if (args.lower is not None and value < args.lower) or (
|
|
133
|
+
args.upper is not None and value > args.upper
|
|
134
|
+
):
|
|
135
|
+
raise RangeBoundsError(f"{value} is outside --lower/--upper")
|
|
136
|
+
out.write(compact(ints, dialect=_dialect(args), width=args.width) + "\n")
|
|
137
|
+
elif args.command in ("normalize", "normalise"):
|
|
138
|
+
target = DIALECTS[args.to] if args.to else _dialect(args)
|
|
139
|
+
out.write(_ranges(args).format(target, width=args.width) + "\n")
|
|
140
|
+
elif args.command == "count":
|
|
141
|
+
out.write(f"{_ranges(args).count}\n")
|
|
142
|
+
elif args.command == "contains":
|
|
143
|
+
ranges = _ranges(args)
|
|
144
|
+
found = [value in ranges for value in args.values]
|
|
145
|
+
for value, hit in zip(args.values, found, strict=True):
|
|
146
|
+
out.write(f"{value} {'yes' if hit else 'no'}\n")
|
|
147
|
+
return 0 if all(found) else 2
|
|
148
|
+
else:
|
|
149
|
+
if args.lower is None or args.upper is None:
|
|
150
|
+
raise ValueError("complement needs --lower and --upper")
|
|
151
|
+
complement = _ranges(args).complement(args.lower, args.upper)
|
|
152
|
+
out.write(complement.format(_dialect(args)) + "\n")
|
|
153
|
+
return 0
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
157
|
+
"""Run the CLI and return its exit code."""
|
|
158
|
+
args = build_parser().parse_args(argv)
|
|
159
|
+
try:
|
|
160
|
+
return _run(args)
|
|
161
|
+
except RangeError as exc:
|
|
162
|
+
print(f"intranges: error: {exc}", file=sys.stderr)
|
|
163
|
+
if exc.pointer():
|
|
164
|
+
print(exc.pointer(), file=sys.stderr)
|
|
165
|
+
return 1
|
|
166
|
+
except ValueError as exc:
|
|
167
|
+
print(f"intranges: error: {exc}", file=sys.stderr)
|
|
168
|
+
return 1
|
|
169
|
+
except BrokenPipeError:
|
|
170
|
+
return 0
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
if __name__ == "__main__": # pragma: no cover
|
|
174
|
+
sys.exit(main())
|
intranges/_compact.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Collapse integers into a canonical range string."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
|
|
7
|
+
from . import _spans as sp
|
|
8
|
+
from ._dialect import DEFAULT, Dialect
|
|
9
|
+
from ._errors import RangeBoundsError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _number(value: int, width: int | None) -> str:
|
|
13
|
+
return str(value) if width is None else f"{value:0{width}d}"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def format_spans(spans: sp.Spans, dialect: Dialect, width: int | None) -> str:
|
|
17
|
+
"""Write canonical spans in ``dialect``; ``width`` zero-pads every number."""
|
|
18
|
+
if width is not None and width < 1:
|
|
19
|
+
raise ValueError(f"width must be at least 1, got {width}")
|
|
20
|
+
if spans:
|
|
21
|
+
low = spans[0][0]
|
|
22
|
+
if (low < 0 and not dialect.negatives) or (
|
|
23
|
+
dialect.minimum is not None and low < dialect.minimum
|
|
24
|
+
):
|
|
25
|
+
raise RangeBoundsError(f"{low} cannot be written in the {dialect.name} dialect")
|
|
26
|
+
parts = []
|
|
27
|
+
for low, high in spans:
|
|
28
|
+
text = _number(low, width)
|
|
29
|
+
if high != low:
|
|
30
|
+
text += dialect.range_sep + _number(high, width)
|
|
31
|
+
parts.append(text)
|
|
32
|
+
body = dialect.list_sep.join(parts)
|
|
33
|
+
return dialect.prefix + body if body else ""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def compact(
|
|
37
|
+
values: Iterable[int],
|
|
38
|
+
*,
|
|
39
|
+
range_sep: str | None = None,
|
|
40
|
+
list_sep: str | None = None,
|
|
41
|
+
dialect: Dialect = DEFAULT,
|
|
42
|
+
width: int | None = None,
|
|
43
|
+
) -> str:
|
|
44
|
+
"""Collapse integers into the canonical range string, such as ``"1-3,5-6"``.
|
|
45
|
+
|
|
46
|
+
The input is sorted and de-duplicated first, so unsorted or repeated values are
|
|
47
|
+
accepted. Runs of two or more consecutive values become ``low-high``. An empty input
|
|
48
|
+
yields an empty string. An :class:`IntRanges` or a step-1 ``range`` is written from
|
|
49
|
+
its spans without being expanded.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
values: Integers in any order, an :class:`IntRanges` or a ``range``.
|
|
53
|
+
range_sep: Overrides ``dialect.range_sep``.
|
|
54
|
+
list_sep: Overrides ``dialect.list_sep``.
|
|
55
|
+
dialect: Output grammar. Its ``prefix`` (``"bytes="`` for ``HTTP``) is written.
|
|
56
|
+
width: Zero-pad every number to this many characters, as in Slurm hostlists
|
|
57
|
+
such as ``"001-010"``.
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
RangeBoundsError: A value cannot be written in the dialect (negative where
|
|
61
|
+
negatives are off, or below the dialect minimum).
|
|
62
|
+
TypeError: A value is not an integer.
|
|
63
|
+
|
|
64
|
+
>>> compact([6, 5, 1, 3, 2])
|
|
65
|
+
'1-3,5-6'
|
|
66
|
+
>>> compact(range(1, 11), width=3)
|
|
67
|
+
'001-010'
|
|
68
|
+
"""
|
|
69
|
+
from ._intranges import _spans_of
|
|
70
|
+
|
|
71
|
+
chosen = dialect.with_separators(range_sep, list_sep)
|
|
72
|
+
return format_spans(_spans_of(values), chosen, width)
|
intranges/_dialect.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Named range-string dialects and their documented sources."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, replace
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class Dialect:
|
|
10
|
+
"""The grammar used to read and write a range string.
|
|
11
|
+
|
|
12
|
+
Attributes:
|
|
13
|
+
name: Short label used in messages and by the CLI.
|
|
14
|
+
range_sep: Separator between the two bounds of a range, ``"-"`` in ``"1-3"``.
|
|
15
|
+
list_sep: Separator between tokens, ``","`` in ``"1-3,5"``.
|
|
16
|
+
negatives: Accept a leading ``-`` on a bound as a sign.
|
|
17
|
+
stride: Separator introducing a step, ``":"`` in ``"0-10:2"``, or ``None``.
|
|
18
|
+
groups: Also accept the Linux kernel ``"a-b:used/group"`` form after ``stride``.
|
|
19
|
+
open_end: Accept ``"5-"`` meaning "5 to ``upper``" (``upper`` must be given).
|
|
20
|
+
suffix: Accept ``"-5"`` meaning "the last 5 values up to ``upper``" (HTTP style).
|
|
21
|
+
Requires ``negatives=False`` so the leading ``-`` is unambiguous.
|
|
22
|
+
clamp: Clip range ends above ``upper`` down to ``upper`` instead of raising.
|
|
23
|
+
brackets: Accept the whole list wrapped in ``[...]`` (Slurm hostlist style).
|
|
24
|
+
prefix: Optional leading unit such as ``"bytes="``; written back by ``compact``.
|
|
25
|
+
last_alias: Token standing for ``upper``, such as the kernel's ``"N"``.
|
|
26
|
+
all_alias: Token standing for ``lower..upper``, such as the kernel's ``"all"``.
|
|
27
|
+
minimum: Smallest value the dialect allows, or ``None`` for no floor.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
name: str = "default"
|
|
31
|
+
range_sep: str = "-"
|
|
32
|
+
list_sep: str = ","
|
|
33
|
+
negatives: bool = True
|
|
34
|
+
stride: str | None = None
|
|
35
|
+
groups: bool = False
|
|
36
|
+
open_end: bool = True
|
|
37
|
+
suffix: bool = False
|
|
38
|
+
clamp: bool = False
|
|
39
|
+
brackets: bool = False
|
|
40
|
+
prefix: str = ""
|
|
41
|
+
last_alias: str | None = None
|
|
42
|
+
all_alias: str | None = None
|
|
43
|
+
minimum: int | None = None
|
|
44
|
+
|
|
45
|
+
def __post_init__(self) -> None:
|
|
46
|
+
if not self.range_sep:
|
|
47
|
+
raise ValueError("range_sep must not be empty")
|
|
48
|
+
if not self.list_sep:
|
|
49
|
+
raise ValueError("list_sep must not be empty")
|
|
50
|
+
seps = [self.range_sep, self.list_sep] + ([self.stride] if self.stride else [])
|
|
51
|
+
if len(set(seps)) != len(seps):
|
|
52
|
+
raise ValueError(f"separators must be distinct, got {seps!r}")
|
|
53
|
+
for sep in seps:
|
|
54
|
+
if any(ch.isdigit() for ch in sep):
|
|
55
|
+
raise ValueError(f"separator {sep!r} must not contain digits")
|
|
56
|
+
if self.stride == "":
|
|
57
|
+
raise ValueError("stride must be None or a non-empty string")
|
|
58
|
+
if self.groups and self.stride is None:
|
|
59
|
+
raise ValueError("groups=True needs a stride separator")
|
|
60
|
+
if self.suffix and self.negatives:
|
|
61
|
+
raise ValueError("suffix ranges need negatives=False to stay unambiguous")
|
|
62
|
+
for alias in (self.last_alias, self.all_alias):
|
|
63
|
+
if alias is not None and (not alias.isalpha()):
|
|
64
|
+
raise ValueError(f"alias {alias!r} must be alphabetic")
|
|
65
|
+
|
|
66
|
+
def with_separators(self, range_sep: str | None, list_sep: str | None) -> Dialect:
|
|
67
|
+
"""Return a copy with the given separators replaced (``None`` keeps the current)."""
|
|
68
|
+
return replace(
|
|
69
|
+
self,
|
|
70
|
+
range_sep=self.range_sep if range_sep is None else range_sep,
|
|
71
|
+
list_sep=self.list_sep if list_sep is None else list_sep,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
DEFAULT = Dialect()
|
|
76
|
+
"""Signed integers, ``-`` ranges, ``,`` lists, open ends when ``upper`` is given."""
|
|
77
|
+
|
|
78
|
+
PAGES = Dialect(name="pages", negatives=False, open_end=False, minimum=1)
|
|
79
|
+
"""Printer page lists such as CUPS ``-o page-ranges=1-4,7,9-12``. Pages start at 1."""
|
|
80
|
+
|
|
81
|
+
CPULIST = Dialect(
|
|
82
|
+
name="cpulist",
|
|
83
|
+
negatives=False,
|
|
84
|
+
stride=":",
|
|
85
|
+
groups=True,
|
|
86
|
+
open_end=False,
|
|
87
|
+
last_alias="N",
|
|
88
|
+
all_alias="all",
|
|
89
|
+
minimum=0,
|
|
90
|
+
)
|
|
91
|
+
"""Linux cpu lists: ``0-3,8-11``, kernel ``100-2000:2/25``, taskset ``0-10:3``, ``16-N``."""
|
|
92
|
+
|
|
93
|
+
SLURM = Dialect(name="slurm", negatives=False, stride=":", open_end=False, brackets=True, minimum=0)
|
|
94
|
+
"""Slurm ``--array`` indexes such as ``0-15:4`` and hostlist numbers such as ``[1-3,5]``."""
|
|
95
|
+
|
|
96
|
+
HTTP = Dialect(
|
|
97
|
+
name="http",
|
|
98
|
+
negatives=False,
|
|
99
|
+
open_end=True,
|
|
100
|
+
suffix=True,
|
|
101
|
+
clamp=True,
|
|
102
|
+
prefix="bytes=",
|
|
103
|
+
minimum=0,
|
|
104
|
+
)
|
|
105
|
+
"""RFC 9110 byte ranges: ``bytes=0-499,500-``, suffix ``-500``. ``upper`` is length - 1."""
|
|
106
|
+
|
|
107
|
+
DIALECTS: dict[str, Dialect] = {d.name: d for d in (DEFAULT, PAGES, CPULIST, SLURM, HTTP)}
|
|
108
|
+
"""Built-in dialects by name."""
|
intranges/_errors.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Error types raised by intranges.
|
|
2
|
+
|
|
3
|
+
Every error is a :class:`RangeError`, which is itself a :class:`ValueError`, so existing
|
|
4
|
+
``except ValueError`` handlers keep working. Errors raised while reading a string carry
|
|
5
|
+
the full ``text``, the offending ``token`` and its character ``offset`` in ``text``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RangeError(ValueError):
|
|
12
|
+
"""Raised when a range string or a set of integers cannot be handled.
|
|
13
|
+
|
|
14
|
+
Attributes:
|
|
15
|
+
text: The full input string, when the error came from parsing.
|
|
16
|
+
token: The offending token, stripped of surrounding whitespace.
|
|
17
|
+
offset: Zero-based index of ``token`` inside ``text``.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
message: str,
|
|
23
|
+
*,
|
|
24
|
+
text: str | None = None,
|
|
25
|
+
token: str | None = None,
|
|
26
|
+
offset: int | None = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
if offset is not None:
|
|
29
|
+
message = f"{message} at offset {offset}"
|
|
30
|
+
super().__init__(message)
|
|
31
|
+
self.text = text
|
|
32
|
+
self.token = token
|
|
33
|
+
self.offset = offset
|
|
34
|
+
|
|
35
|
+
def pointer(self) -> str:
|
|
36
|
+
"""Return ``text`` with a caret line under the offending token.
|
|
37
|
+
|
|
38
|
+
Returns an empty string when the error does not point into a string.
|
|
39
|
+
"""
|
|
40
|
+
if self.text is None or self.offset is None:
|
|
41
|
+
return ""
|
|
42
|
+
width = max(1, len(self.token or ""))
|
|
43
|
+
return f"{self.text}\n{' ' * self.offset}{'^' * width}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RangeSyntaxError(RangeError):
|
|
47
|
+
"""A token does not match the grammar of the active dialect."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class DescendingRangeError(RangeError):
|
|
51
|
+
"""A range runs from high to low, such as ``"5-1"``."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class RangeOverlapError(RangeError):
|
|
55
|
+
"""A token overlaps or repeats an earlier one while ``on_overlap="reject"``."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class RangeOrderError(RangeError):
|
|
59
|
+
"""A token starts below an earlier one while ``on_unsorted="reject"``."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class RangeBoundsError(RangeError):
|
|
63
|
+
"""A value falls outside ``lower``/``upper`` or the dialect minimum."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class RangeSizeError(RangeError):
|
|
67
|
+
"""Expanding the input would exceed ``max_size`` values or ``max_spans`` spans."""
|
intranges/_intranges.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""A lazy, immutable set of integers stored as spans."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from bisect import bisect_right
|
|
6
|
+
from collections.abc import Iterable, Iterator, Set
|
|
7
|
+
from operator import index
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from . import _spans as sp
|
|
11
|
+
from ._dialect import DEFAULT, Dialect
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class IntRanges(Set[int]):
|
|
15
|
+
"""An immutable set of integers that never expands its ranges.
|
|
16
|
+
|
|
17
|
+
Values are kept as sorted, disjoint, inclusive spans, so ``IntRanges(range(10**12))``
|
|
18
|
+
holds two integers. Membership is a binary search, :attr:`count` is arithmetic and
|
|
19
|
+
iteration streams values in ascending order. Set operators (``|``, ``&``, ``-``,
|
|
20
|
+
``^``, ``<=``, ``<``, ``>=``, ``>``) work span by span when both sides are
|
|
21
|
+
``IntRanges``; any other iterable of ints is converted first. ``==`` and ``hash``
|
|
22
|
+
compare ``IntRanges`` with ``IntRanges`` only, so the hash contract holds. ``in``
|
|
23
|
+
accepts any ``__index__`` integer. ``len()`` stops at ``sys.maxsize``; use ``count``.
|
|
24
|
+
|
|
25
|
+
>>> r = IntRanges.parse("1-3,10-12")
|
|
26
|
+
>>> 11 in r, r.count, str(r | IntRanges([4]))
|
|
27
|
+
(True, 6, '1-4,10-12')
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
__slots__ = ("_spans",)
|
|
31
|
+
_spans: sp.Spans
|
|
32
|
+
|
|
33
|
+
def __init__(self, values: Iterable[int] = ()) -> None:
|
|
34
|
+
"""Build from any iterable of ints; a ``range`` with step 1 is not expanded."""
|
|
35
|
+
if isinstance(values, IntRanges):
|
|
36
|
+
self._spans = values._spans
|
|
37
|
+
else:
|
|
38
|
+
self._spans = sp.from_ints(values)
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def from_spans(cls, spans: Iterable[tuple[int, int]]) -> IntRanges:
|
|
42
|
+
"""Build from inclusive ``(low, high)`` pairs in any order, overlapping or not.
|
|
43
|
+
|
|
44
|
+
Raises :class:`ValueError` when a pair has ``low > high`` and :class:`TypeError`
|
|
45
|
+
when a bound is not an integer.
|
|
46
|
+
"""
|
|
47
|
+
pairs = [(index(low), index(high)) for low, high in spans]
|
|
48
|
+
for low, high in pairs:
|
|
49
|
+
if low > high:
|
|
50
|
+
raise ValueError(f"span ({low}, {high}) has low greater than high")
|
|
51
|
+
return cls._wrap(sp.coalesce(pairs))
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def parse(cls, text: str, **options: Any) -> IntRanges:
|
|
55
|
+
"""Read a range string; keyword options are those of :func:`parse_ranges`."""
|
|
56
|
+
from ._parse import parse_ranges
|
|
57
|
+
|
|
58
|
+
return parse_ranges(text, **options)
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def _wrap(cls, spans: sp.Spans) -> IntRanges:
|
|
62
|
+
obj = cls.__new__(cls)
|
|
63
|
+
obj._spans = spans
|
|
64
|
+
return obj
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def spans(self) -> tuple[tuple[int, int], ...]:
|
|
68
|
+
"""The canonical inclusive ``(low, high)`` spans, ascending and non-adjacent."""
|
|
69
|
+
return self._spans
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def count(self) -> int:
|
|
73
|
+
"""Number of values, as an unbounded int."""
|
|
74
|
+
return sp.count(self._spans)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def first(self) -> int:
|
|
78
|
+
"""Smallest value. Raises :class:`ValueError` when empty."""
|
|
79
|
+
if not self._spans:
|
|
80
|
+
raise ValueError("first of an empty IntRanges")
|
|
81
|
+
return self._spans[0][0]
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def last(self) -> int:
|
|
85
|
+
"""Largest value. Raises :class:`ValueError` when empty."""
|
|
86
|
+
if not self._spans:
|
|
87
|
+
raise ValueError("last of an empty IntRanges")
|
|
88
|
+
return self._spans[-1][1]
|
|
89
|
+
|
|
90
|
+
def __contains__(self, value: object) -> bool:
|
|
91
|
+
try:
|
|
92
|
+
value = index(value) # type: ignore[arg-type]
|
|
93
|
+
except TypeError:
|
|
94
|
+
return False
|
|
95
|
+
i = bisect_right(self._spans, value, key=lambda span: span[0]) - 1
|
|
96
|
+
return i >= 0 and value <= self._spans[i][1]
|
|
97
|
+
|
|
98
|
+
def __iter__(self) -> Iterator[int]:
|
|
99
|
+
for low, high in self._spans:
|
|
100
|
+
yield from range(low, high + 1)
|
|
101
|
+
|
|
102
|
+
def __reversed__(self) -> Iterator[int]:
|
|
103
|
+
for low, high in reversed(self._spans):
|
|
104
|
+
yield from range(high, low - 1, -1)
|
|
105
|
+
|
|
106
|
+
def __len__(self) -> int:
|
|
107
|
+
return self.count
|
|
108
|
+
|
|
109
|
+
def __bool__(self) -> bool:
|
|
110
|
+
return bool(self._spans)
|
|
111
|
+
|
|
112
|
+
def __hash__(self) -> int:
|
|
113
|
+
return hash((IntRanges, self._spans))
|
|
114
|
+
|
|
115
|
+
def __eq__(self, other: object) -> bool:
|
|
116
|
+
if isinstance(other, IntRanges):
|
|
117
|
+
return self._spans == other._spans
|
|
118
|
+
return NotImplemented
|
|
119
|
+
|
|
120
|
+
def __repr__(self) -> str:
|
|
121
|
+
return f"IntRanges({self.format()!r})"
|
|
122
|
+
|
|
123
|
+
def __str__(self) -> str:
|
|
124
|
+
return self.format()
|
|
125
|
+
|
|
126
|
+
def __reduce__(self) -> tuple[Any, ...]:
|
|
127
|
+
return (type(self).from_spans, (self._spans,))
|
|
128
|
+
|
|
129
|
+
def format(self, dialect: Dialect = DEFAULT, *, width: int | None = None) -> str:
|
|
130
|
+
"""Write the canonical range string; see :func:`compact` for the options."""
|
|
131
|
+
from ._compact import format_spans
|
|
132
|
+
|
|
133
|
+
return format_spans(self._spans, dialect, width)
|
|
134
|
+
|
|
135
|
+
def complement(self, lower: int, upper: int) -> IntRanges:
|
|
136
|
+
"""Values in ``lower..upper`` (inclusive) that are not in this set."""
|
|
137
|
+
return self._wrap(sp.complement(self._spans, lower, upper))
|
|
138
|
+
|
|
139
|
+
def union(self, *others: Iterable[int]) -> IntRanges:
|
|
140
|
+
spans = self._spans
|
|
141
|
+
for other in others:
|
|
142
|
+
spans = sp.union(spans, _spans_of(other))
|
|
143
|
+
return self._wrap(spans)
|
|
144
|
+
|
|
145
|
+
def intersection(self, *others: Iterable[int]) -> IntRanges:
|
|
146
|
+
spans = self._spans
|
|
147
|
+
for other in others:
|
|
148
|
+
spans = sp.intersection(spans, _spans_of(other))
|
|
149
|
+
return self._wrap(spans)
|
|
150
|
+
|
|
151
|
+
def difference(self, *others: Iterable[int]) -> IntRanges:
|
|
152
|
+
spans = self._spans
|
|
153
|
+
for other in others:
|
|
154
|
+
spans = sp.difference(spans, _spans_of(other))
|
|
155
|
+
return self._wrap(spans)
|
|
156
|
+
|
|
157
|
+
def symmetric_difference(self, other: Iterable[int]) -> IntRanges:
|
|
158
|
+
theirs = _spans_of(other)
|
|
159
|
+
mine = self._spans
|
|
160
|
+
return self._wrap(sp.union(sp.difference(mine, theirs), sp.difference(theirs, mine)))
|
|
161
|
+
|
|
162
|
+
def issubset(self, other: Iterable[int]) -> bool:
|
|
163
|
+
return sp.is_subset(self._spans, _spans_of(other))
|
|
164
|
+
|
|
165
|
+
def issuperset(self, other: Iterable[int]) -> bool:
|
|
166
|
+
return sp.is_subset(_spans_of(other), self._spans)
|
|
167
|
+
|
|
168
|
+
def isdisjoint(self, other: Iterable[int]) -> bool:
|
|
169
|
+
return not sp.intersection(self._spans, _spans_of(other))
|
|
170
|
+
|
|
171
|
+
__or__ = __ror__ = union # type: ignore[assignment]
|
|
172
|
+
__and__ = __rand__ = intersection
|
|
173
|
+
__xor__ = __rxor__ = symmetric_difference # type: ignore[assignment]
|
|
174
|
+
__sub__ = difference
|
|
175
|
+
|
|
176
|
+
def __rsub__(self, other: Iterable[int]) -> IntRanges:
|
|
177
|
+
return self._wrap(sp.difference(_spans_of(other), self._spans))
|
|
178
|
+
|
|
179
|
+
def __le__(self, other: Any) -> bool:
|
|
180
|
+
return self.issubset(other) if isinstance(other, Set) else NotImplemented
|
|
181
|
+
|
|
182
|
+
def __ge__(self, other: Any) -> bool:
|
|
183
|
+
return self.issuperset(other) if isinstance(other, Set) else NotImplemented
|
|
184
|
+
|
|
185
|
+
def __lt__(self, other: Any) -> bool:
|
|
186
|
+
if not isinstance(other, Set):
|
|
187
|
+
return NotImplemented
|
|
188
|
+
theirs = _spans_of(other)
|
|
189
|
+
return sp.is_subset(self._spans, theirs) and sp.count(self._spans) < sp.count(theirs)
|
|
190
|
+
|
|
191
|
+
def __gt__(self, other: Any) -> bool:
|
|
192
|
+
if not isinstance(other, Set):
|
|
193
|
+
return NotImplemented
|
|
194
|
+
theirs = _spans_of(other)
|
|
195
|
+
return sp.is_subset(theirs, self._spans) and sp.count(theirs) < sp.count(self._spans)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _spans_of(values: Iterable[int]) -> sp.Spans:
|
|
199
|
+
return values._spans if isinstance(values, IntRanges) else sp.from_ints(values)
|
intranges/_parse.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Read range strings into lists, lazy sets, counts and canonical strings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from . import _spans as sp
|
|
8
|
+
from ._dialect import DEFAULT, Dialect
|
|
9
|
+
from ._errors import RangeError, RangeOrderError, RangeOverlapError, RangeSizeError
|
|
10
|
+
from ._intranges import IntRanges
|
|
11
|
+
from ._tokens import Piece, scan
|
|
12
|
+
|
|
13
|
+
DEFAULT_MAX_SIZE = 10_000_000
|
|
14
|
+
"""Default cap on the number of values :func:`parse` will put in a list."""
|
|
15
|
+
|
|
16
|
+
DEFAULT_MAX_SPANS = 1_000_000
|
|
17
|
+
"""Default cap on the number of separate spans a strided input may create."""
|
|
18
|
+
|
|
19
|
+
OverlapPolicy = Literal["merge", "reject"]
|
|
20
|
+
OrderPolicy = Literal["allow", "reject"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _fail(kind: type[RangeError], message: str, text: str, piece: Piece) -> RangeError:
|
|
24
|
+
return kind(message, text=text, token=piece.token, offset=piece.offset)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def parse(
|
|
28
|
+
text: str,
|
|
29
|
+
*,
|
|
30
|
+
range_sep: str | None = None,
|
|
31
|
+
list_sep: str | None = None,
|
|
32
|
+
dialect: Dialect = DEFAULT,
|
|
33
|
+
lower: int | None = None,
|
|
34
|
+
upper: int | None = None,
|
|
35
|
+
max_size: int | None = DEFAULT_MAX_SIZE,
|
|
36
|
+
) -> list[int]:
|
|
37
|
+
"""Expand a range string such as ``"1-5,8,10-12"`` into a list of ints.
|
|
38
|
+
|
|
39
|
+
Values come out in input order and repeats are kept, so ``parse("3,1-2,2")`` is
|
|
40
|
+
``[3, 1, 2, 2]``. Use :func:`parse_ranges` for set semantics without expansion.
|
|
41
|
+
Whitespace around tokens and separators is ignored. An empty string gives ``[]``.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
text: The range string.
|
|
45
|
+
range_sep: Overrides ``dialect.range_sep`` (default ``"-"``).
|
|
46
|
+
list_sep: Overrides ``dialect.list_sep`` (default ``","``).
|
|
47
|
+
dialect: Grammar to read, such as ``CPULIST`` or ``HTTP``.
|
|
48
|
+
lower: Smallest allowed value; also the start of an ``all`` alias.
|
|
49
|
+
upper: Largest allowed value; also the end of ``"5-"``, ``"-5"`` and ``N``.
|
|
50
|
+
max_size: Refuse to build more values than this (checked before allocating);
|
|
51
|
+
``None`` lifts the cap.
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
RangeSyntaxError: A token does not match the dialect grammar.
|
|
55
|
+
DescendingRangeError: A range runs high to low, such as ``"5-1"``.
|
|
56
|
+
RangeBoundsError: A value is outside ``lower``/``upper`` or the dialect minimum.
|
|
57
|
+
RangeSizeError: The expansion would exceed ``max_size``.
|
|
58
|
+
ValueError: The separators are empty or clash, or ``lower > upper``.
|
|
59
|
+
|
|
60
|
+
>>> parse("1-3,7")
|
|
61
|
+
[1, 2, 3, 7]
|
|
62
|
+
>>> parse("0-10:3", dialect=CPULIST)
|
|
63
|
+
[0, 3, 6, 9]
|
|
64
|
+
"""
|
|
65
|
+
chosen = dialect.with_separators(range_sep, list_sep)
|
|
66
|
+
pieces = scan(text, chosen, lower, upper)
|
|
67
|
+
total = 0
|
|
68
|
+
for piece in pieces:
|
|
69
|
+
total += piece.size()
|
|
70
|
+
if max_size is not None and total > max_size:
|
|
71
|
+
raise _fail(RangeSizeError, f"expansion exceeds max_size={max_size}", text, piece)
|
|
72
|
+
result: list[int] = []
|
|
73
|
+
for piece in pieces:
|
|
74
|
+
for low, high in piece.spans():
|
|
75
|
+
result.extend(range(low, high + 1))
|
|
76
|
+
return result
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def parse_ranges(
|
|
80
|
+
text: str,
|
|
81
|
+
*,
|
|
82
|
+
range_sep: str | None = None,
|
|
83
|
+
list_sep: str | None = None,
|
|
84
|
+
dialect: Dialect = DEFAULT,
|
|
85
|
+
lower: int | None = None,
|
|
86
|
+
upper: int | None = None,
|
|
87
|
+
on_overlap: OverlapPolicy = "merge",
|
|
88
|
+
on_unsorted: OrderPolicy = "allow",
|
|
89
|
+
max_spans: int | None = DEFAULT_MAX_SPANS,
|
|
90
|
+
) -> IntRanges:
|
|
91
|
+
"""Read a range string into a lazy :class:`IntRanges` without expanding it.
|
|
92
|
+
|
|
93
|
+
``parse_ranges("0-10000000000")`` costs two integers. Overlapping, repeated and
|
|
94
|
+
unsorted tokens are merged by default.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
on_overlap: ``"merge"`` unions overlapping or repeated tokens; ``"reject"``
|
|
98
|
+
raises :class:`RangeOverlapError` naming the later of the two tokens.
|
|
99
|
+
on_unsorted: ``"allow"`` accepts tokens in any order; ``"reject"`` raises
|
|
100
|
+
:class:`RangeOrderError` when a token starts below the previous token.
|
|
101
|
+
max_spans: Cap on separate spans created by strides such as ``"0-10:2"``;
|
|
102
|
+
``None`` lifts it.
|
|
103
|
+
Other options are those of :func:`parse`.
|
|
104
|
+
|
|
105
|
+
>>> parse_ranges("5,1-3,2").spans
|
|
106
|
+
((1, 3), (5, 5))
|
|
107
|
+
"""
|
|
108
|
+
if on_overlap not in ("merge", "reject"):
|
|
109
|
+
raise ValueError(f"on_overlap must be 'merge' or 'reject', got {on_overlap!r}")
|
|
110
|
+
if on_unsorted not in ("allow", "reject"):
|
|
111
|
+
raise ValueError(f"on_unsorted must be 'allow' or 'reject', got {on_unsorted!r}")
|
|
112
|
+
chosen = dialect.with_separators(range_sep, list_sep)
|
|
113
|
+
pieces = scan(text, chosen, lower, upper)
|
|
114
|
+
total = 0
|
|
115
|
+
for index, piece in enumerate(pieces):
|
|
116
|
+
total += piece.span_count()
|
|
117
|
+
if max_spans is not None and total > max_spans:
|
|
118
|
+
raise _fail(RangeSizeError, f"input exceeds max_spans={max_spans}", text, piece)
|
|
119
|
+
if on_unsorted == "reject" and index and piece.start < pieces[index - 1].start:
|
|
120
|
+
raise _fail(RangeOrderError, "token starts below the previous token", text, piece)
|
|
121
|
+
tagged = sorted(
|
|
122
|
+
(low, high, index) for index, piece in enumerate(pieces) for low, high in piece.spans()
|
|
123
|
+
)
|
|
124
|
+
if on_overlap == "reject":
|
|
125
|
+
_reject_overlaps(tagged, pieces, text)
|
|
126
|
+
return IntRanges._wrap(sp.coalesce((low, high) for low, high, _ in tagged))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _reject_overlaps(tagged: list[tuple[int, int, int]], pieces: list[Piece], text: str) -> None:
|
|
130
|
+
reach, owner = None, -1
|
|
131
|
+
for low, high, index in tagged:
|
|
132
|
+
if reach is not None and low <= reach:
|
|
133
|
+
later = pieces[max(index, owner)]
|
|
134
|
+
raise _fail(RangeOverlapError, "token overlaps an earlier token", text, later)
|
|
135
|
+
if reach is None or high > reach:
|
|
136
|
+
reach, owner = high, index
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def count(
|
|
140
|
+
text: str,
|
|
141
|
+
*,
|
|
142
|
+
range_sep: str | None = None,
|
|
143
|
+
list_sep: str | None = None,
|
|
144
|
+
dialect: Dialect = DEFAULT,
|
|
145
|
+
lower: int | None = None,
|
|
146
|
+
upper: int | None = None,
|
|
147
|
+
max_spans: int | None = DEFAULT_MAX_SPANS,
|
|
148
|
+
) -> int:
|
|
149
|
+
"""Number of distinct values in a range string, computed without expanding it.
|
|
150
|
+
|
|
151
|
+
Options are those of :func:`parse_ranges`.
|
|
152
|
+
|
|
153
|
+
>>> count("1-1000000,500000-2000000")
|
|
154
|
+
2000000
|
|
155
|
+
"""
|
|
156
|
+
return parse_ranges(
|
|
157
|
+
text,
|
|
158
|
+
range_sep=range_sep,
|
|
159
|
+
list_sep=list_sep,
|
|
160
|
+
dialect=dialect,
|
|
161
|
+
lower=lower,
|
|
162
|
+
upper=upper,
|
|
163
|
+
max_spans=max_spans,
|
|
164
|
+
).count
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def normalize(
|
|
168
|
+
text: str,
|
|
169
|
+
*,
|
|
170
|
+
range_sep: str | None = None,
|
|
171
|
+
list_sep: str | None = None,
|
|
172
|
+
dialect: Dialect = DEFAULT,
|
|
173
|
+
lower: int | None = None,
|
|
174
|
+
upper: int | None = None,
|
|
175
|
+
output: Dialect | None = None,
|
|
176
|
+
width: int | None = None,
|
|
177
|
+
) -> str:
|
|
178
|
+
"""Rewrite a range string in canonical form: sorted, merged, minimal.
|
|
179
|
+
|
|
180
|
+
``output`` picks the dialect to write (default: the input dialect with the given
|
|
181
|
+
separators) and ``width`` zero-pads numbers. Other options are those of
|
|
182
|
+
:func:`parse_ranges`.
|
|
183
|
+
|
|
184
|
+
>>> normalize("8, 1-3, 2, 4")
|
|
185
|
+
'1-4,8'
|
|
186
|
+
"""
|
|
187
|
+
chosen = dialect.with_separators(range_sep, list_sep)
|
|
188
|
+
ranges = parse_ranges(text, dialect=chosen, lower=lower, upper=upper)
|
|
189
|
+
return ranges.format(output if output is not None else chosen, width=width)
|
intranges/_spans.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Algebra on canonical spans: sorted, disjoint, non-adjacent, inclusive ``(low, high)``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from operator import index
|
|
7
|
+
|
|
8
|
+
Span = tuple[int, int]
|
|
9
|
+
Spans = tuple[Span, ...]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def coalesce(spans: Iterable[Span]) -> Spans:
|
|
13
|
+
"""Sort spans and merge every overlapping or adjacent pair."""
|
|
14
|
+
out: list[Span] = []
|
|
15
|
+
for low, high in sorted(spans):
|
|
16
|
+
if out and low <= out[-1][1] + 1:
|
|
17
|
+
if high > out[-1][1]:
|
|
18
|
+
out[-1] = (out[-1][0], high)
|
|
19
|
+
else:
|
|
20
|
+
out.append((low, high))
|
|
21
|
+
return tuple(out)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def from_ints(values: Iterable[int]) -> Spans:
|
|
25
|
+
"""Build canonical spans from integers, in any order, with repeats.
|
|
26
|
+
|
|
27
|
+
Raises :class:`TypeError` for a value that is not an integer.
|
|
28
|
+
"""
|
|
29
|
+
if isinstance(values, range) and values.step == 1:
|
|
30
|
+
return ((values.start, values.stop - 1),) if values.start < values.stop else ()
|
|
31
|
+
return coalesce((value, value) for value in map(index, values))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def union(a: Spans, b: Spans) -> Spans:
|
|
35
|
+
return coalesce(a + b)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def intersection(a: Spans, b: Spans) -> Spans:
|
|
39
|
+
out: list[Span] = []
|
|
40
|
+
i = j = 0
|
|
41
|
+
while i < len(a) and j < len(b):
|
|
42
|
+
low = max(a[i][0], b[j][0])
|
|
43
|
+
high = min(a[i][1], b[j][1])
|
|
44
|
+
if low <= high:
|
|
45
|
+
out.append((low, high))
|
|
46
|
+
if a[i][1] < b[j][1]:
|
|
47
|
+
i += 1
|
|
48
|
+
else:
|
|
49
|
+
j += 1
|
|
50
|
+
return tuple(out)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def difference(a: Spans, b: Spans) -> Spans:
|
|
54
|
+
out: list[Span] = []
|
|
55
|
+
j = 0
|
|
56
|
+
for low, high in a:
|
|
57
|
+
while j < len(b) and b[j][1] < low:
|
|
58
|
+
j += 1
|
|
59
|
+
k = j
|
|
60
|
+
while k < len(b) and b[k][0] <= high:
|
|
61
|
+
if b[k][0] > low:
|
|
62
|
+
out.append((low, b[k][0] - 1))
|
|
63
|
+
low = max(low, b[k][1] + 1)
|
|
64
|
+
k += 1
|
|
65
|
+
if low <= high:
|
|
66
|
+
out.append((low, high))
|
|
67
|
+
return tuple(out)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def complement(a: Spans, lower: int, upper: int) -> Spans:
|
|
71
|
+
if lower > upper:
|
|
72
|
+
raise ValueError(f"lower {lower} is greater than upper {upper}")
|
|
73
|
+
return difference(((lower, upper),), a)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def is_subset(a: Spans, b: Spans) -> bool:
|
|
77
|
+
return not difference(a, b)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def count(a: Spans) -> int:
|
|
81
|
+
return sum(high - low + 1 for low, high in a)
|
intranges/_tokens.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Split a range string into validated pieces, one per token, without expanding them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Callable, Iterator
|
|
7
|
+
from functools import cache
|
|
8
|
+
from typing import NamedTuple, NoReturn
|
|
9
|
+
|
|
10
|
+
from ._dialect import Dialect
|
|
11
|
+
from ._errors import DescendingRangeError, RangeBoundsError, RangeError, RangeSyntaxError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Piece(NamedTuple):
|
|
15
|
+
"""One token: groups of ``used`` values every ``step`` from ``start`` up to ``end``."""
|
|
16
|
+
|
|
17
|
+
token: str
|
|
18
|
+
offset: int
|
|
19
|
+
start: int
|
|
20
|
+
end: int
|
|
21
|
+
step: int = 1
|
|
22
|
+
used: int = 1
|
|
23
|
+
|
|
24
|
+
def size(self) -> int:
|
|
25
|
+
if self.step == 1:
|
|
26
|
+
return self.end - self.start + 1
|
|
27
|
+
full, rest = divmod(self.end - self.start + 1, self.step)
|
|
28
|
+
return full * self.used + min(rest, self.used)
|
|
29
|
+
|
|
30
|
+
def span_count(self) -> int:
|
|
31
|
+
return 1 if self.step == 1 else (self.end - self.start) // self.step + 1
|
|
32
|
+
|
|
33
|
+
def last(self) -> int:
|
|
34
|
+
if self.step == 1:
|
|
35
|
+
return self.end
|
|
36
|
+
first_of_last = self.start + (self.end - self.start) // self.step * self.step
|
|
37
|
+
return min(first_of_last + self.used - 1, self.end)
|
|
38
|
+
|
|
39
|
+
def spans(self) -> Iterator[tuple[int, int]]:
|
|
40
|
+
if self.step == 1:
|
|
41
|
+
yield (self.start, self.end)
|
|
42
|
+
return
|
|
43
|
+
for low in range(self.start, self.end + 1, self.step):
|
|
44
|
+
yield (low, min(low + self.used - 1, self.end))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@cache
|
|
48
|
+
def _pattern(dialect: Dialect) -> re.Pattern[str]:
|
|
49
|
+
num = r"-?\d+" if dialect.negatives else r"\d+"
|
|
50
|
+
if dialect.last_alias:
|
|
51
|
+
num = rf"(?:{num}|{re.escape(dialect.last_alias)})"
|
|
52
|
+
stride = ""
|
|
53
|
+
if dialect.stride:
|
|
54
|
+
stride = rf"(?:\s*{re.escape(dialect.stride)}\s*(?P<step>\d+)(?:\s*/\s*(?P<grp>\d+))?)?"
|
|
55
|
+
sep = re.escape(dialect.range_sep)
|
|
56
|
+
return re.compile(rf"(?P<lo>{num})?(?:\s*(?P<sep>{sep})\s*(?P<hi>{num})?)?{stride}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _strip_frame(text: str, dialect: Dialect) -> tuple[str, int]:
|
|
60
|
+
"""Drop an optional unit prefix and enclosing brackets, returning body and offset."""
|
|
61
|
+
body, base = text, 0
|
|
62
|
+
lead = len(body) - len(body.lstrip())
|
|
63
|
+
if dialect.prefix and body[lead:].lower().startswith(dialect.prefix.lower()):
|
|
64
|
+
base = lead + len(dialect.prefix)
|
|
65
|
+
body = text[base:]
|
|
66
|
+
if dialect.brackets:
|
|
67
|
+
inner = body.strip()
|
|
68
|
+
if inner.startswith("[") and inner.endswith("]"):
|
|
69
|
+
base += body.index("[") + 1
|
|
70
|
+
body = inner[1:-1]
|
|
71
|
+
return body, base
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _chunks(body: str, base: int, sep: str) -> Iterator[tuple[str, int]]:
|
|
75
|
+
pos = 0
|
|
76
|
+
while True:
|
|
77
|
+
cut = body.find(sep, pos)
|
|
78
|
+
chunk = body[pos:] if cut < 0 else body[pos:cut]
|
|
79
|
+
yield chunk.strip(), base + pos + len(chunk) - len(chunk.lstrip())
|
|
80
|
+
if cut < 0:
|
|
81
|
+
return
|
|
82
|
+
pos = cut + len(sep)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def scan(text: str, dialect: Dialect, lower: int | None, upper: int | None) -> list[Piece]:
|
|
86
|
+
"""Return one validated :class:`Piece` per token of ``text``, in input order."""
|
|
87
|
+
if lower is not None and upper is not None and lower > upper:
|
|
88
|
+
raise ValueError(f"lower {lower} is greater than upper {upper}")
|
|
89
|
+
body, base = _strip_frame(text, dialect)
|
|
90
|
+
if not body.strip():
|
|
91
|
+
return []
|
|
92
|
+
return [
|
|
93
|
+
_read(token, offset, text, dialect, lower, upper)
|
|
94
|
+
for token, offset in _chunks(body, base, dialect.list_sep)
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
Fail = Callable[[type[RangeError], str], NoReturn]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _read(
|
|
102
|
+
token: str, offset: int, text: str, d: Dialect, lower: int | None, upper: int | None
|
|
103
|
+
) -> Piece:
|
|
104
|
+
def fail(kind: type[RangeError], message: str) -> NoReturn:
|
|
105
|
+
raise kind(message, text=text, token=token, offset=offset)
|
|
106
|
+
|
|
107
|
+
if not token:
|
|
108
|
+
fail(RangeSyntaxError, "empty range token")
|
|
109
|
+
floor = _floor(lower, d.minimum)
|
|
110
|
+
if d.all_alias and token.lower() == d.all_alias.lower():
|
|
111
|
+
if floor is None or upper is None:
|
|
112
|
+
fail(RangeBoundsError, f"{token!r} needs lower and upper bounds")
|
|
113
|
+
if floor > upper:
|
|
114
|
+
fail(RangeBoundsError, f"{token!r} is empty: lower {floor} exceeds upper {upper}")
|
|
115
|
+
return Piece(token, offset, floor, upper)
|
|
116
|
+
match = _pattern(d).fullmatch(token)
|
|
117
|
+
if match is None or (match["lo"] is None and match["sep"] is None):
|
|
118
|
+
fail(RangeSyntaxError, f"invalid range token {token!r} for the {d.name} dialect")
|
|
119
|
+
|
|
120
|
+
def value(raw: str) -> int:
|
|
121
|
+
if raw == d.last_alias:
|
|
122
|
+
if upper is None:
|
|
123
|
+
fail(RangeBoundsError, f"{raw!r} needs an upper bound")
|
|
124
|
+
return upper
|
|
125
|
+
return int(raw)
|
|
126
|
+
|
|
127
|
+
lo, hi = match["lo"], match["hi"]
|
|
128
|
+
step = int(match["step"]) if d.stride and match["step"] else None
|
|
129
|
+
if match["sep"] is None:
|
|
130
|
+
if step is not None:
|
|
131
|
+
fail(RangeSyntaxError, f"a stride needs a range in {token!r}")
|
|
132
|
+
start = end = value(lo)
|
|
133
|
+
elif lo is None:
|
|
134
|
+
if not d.suffix or upper is None:
|
|
135
|
+
hint = "needs an upper bound" if d.suffix else "is not allowed"
|
|
136
|
+
fail(RangeSyntaxError, f"suffix range {token!r} {hint}")
|
|
137
|
+
if hi is None or not hi.isdigit() or int(hi) == 0:
|
|
138
|
+
fail(RangeSyntaxError, f"suffix range {token!r} needs a positive length")
|
|
139
|
+
start, end = upper - int(hi) + 1, upper
|
|
140
|
+
if d.clamp and d.minimum is not None:
|
|
141
|
+
start = max(start, d.minimum)
|
|
142
|
+
if start > end:
|
|
143
|
+
fail(RangeBoundsError, f"suffix range {token!r} is unsatisfiable")
|
|
144
|
+
elif hi is None:
|
|
145
|
+
if not d.open_end:
|
|
146
|
+
fail(RangeSyntaxError, f"open-ended range {token!r} is not allowed")
|
|
147
|
+
if upper is None:
|
|
148
|
+
fail(RangeBoundsError, f"open-ended range {token!r} needs an upper bound")
|
|
149
|
+
start, end = value(lo), upper
|
|
150
|
+
if start > end:
|
|
151
|
+
fail(RangeBoundsError, f"{start} is above the upper bound {upper}")
|
|
152
|
+
else:
|
|
153
|
+
start, end = value(lo), value(hi)
|
|
154
|
+
if end < start:
|
|
155
|
+
fail(DescendingRangeError, f"descending range {token!r}")
|
|
156
|
+
if d.clamp and upper is not None and start <= upper < end:
|
|
157
|
+
end = upper
|
|
158
|
+
piece = Piece(token, offset, start, end, *_stride(step, match.groupdict().get("grp"), d, fail))
|
|
159
|
+
if floor is not None and piece.start < floor:
|
|
160
|
+
fail(RangeBoundsError, f"{piece.start} is below the lower bound {floor}")
|
|
161
|
+
if upper is not None and piece.last() > upper:
|
|
162
|
+
fail(RangeBoundsError, f"{piece.last()} is above the upper bound {upper}")
|
|
163
|
+
return piece
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _floor(lower: int | None, minimum: int | None) -> int | None:
|
|
167
|
+
if lower is None or minimum is None:
|
|
168
|
+
return minimum if lower is None else lower
|
|
169
|
+
return max(lower, minimum)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _stride(step: int | None, grp: str | None, d: Dialect, fail: Fail) -> tuple[int, int]:
|
|
173
|
+
if step is None:
|
|
174
|
+
return 1, 1
|
|
175
|
+
if grp is not None and not d.groups:
|
|
176
|
+
fail(RangeSyntaxError, "used/group strides are not allowed")
|
|
177
|
+
used, size = (1, step) if grp is None else (step, int(grp))
|
|
178
|
+
if size == 0 or used == 0 or used > size:
|
|
179
|
+
fail(RangeSyntaxError, "stride must satisfy 0 < used <= group")
|
|
180
|
+
return (1, 1) if used == size else (size, used)
|
intranges/py.typed
ADDED
|
File without changes
|
|
@@ -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,15 @@
|
|
|
1
|
+
intranges/__init__.py,sha256=F0GqUHgnFDZ5HJyBIYcYsCf39_3t2LqFIGqp-E6RfyY,1297
|
|
2
|
+
intranges/_cli.py,sha256=g3XJ_Yxqct7fkF8CjfhROyLyE4Y2mLJfPkwN7cjiA1U,7274
|
|
3
|
+
intranges/_compact.py,sha256=rM7d4rycUri_tivN60_v0mkd-hRR-hL7VOhl1hPItUc,2597
|
|
4
|
+
intranges/_dialect.py,sha256=vUOXP6Nw4BDuqmvflCBeV5o7Qzzj7ULo-tmCoF5qFzw,4436
|
|
5
|
+
intranges/_errors.py,sha256=nMOKJr5UPwepi6lJLL8cVYOm9AisrOrU5Z4-MMKoVEk,2120
|
|
6
|
+
intranges/_intranges.py,sha256=SaUF9IASFDPuQQoMkg0X_OwT5YwLBL64yH6BwqRSi6E,7289
|
|
7
|
+
intranges/_parse.py,sha256=HIr84P7muFV-069R2Y6byY92L5Ozm19J5e5gt8u3uxE,7003
|
|
8
|
+
intranges/_spans.py,sha256=MEg3FyiPwZPPGv1PxSLqInz_8zImGPsyD5tXySurAU0,2254
|
|
9
|
+
intranges/_tokens.py,sha256=J4iGOfc8VC-mdYSekZOo1OsSrcXLIBkcTqDuW04Eqjc,7046
|
|
10
|
+
intranges/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
intranges-0.2.0.dist-info/METADATA,sha256=gO3s5yYFYHBNBbCVnx1cgiNUN0JOLSM2AtcPkvsITOQ,6423
|
|
12
|
+
intranges-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
13
|
+
intranges-0.2.0.dist-info/entry_points.txt,sha256=1wdROgR9jXqFjx8In01wHdboqYSSMLyk7XpOy6J0q8s,50
|
|
14
|
+
intranges-0.2.0.dist-info/licenses/LICENSE,sha256=_WmABlQG__oqlx4Rzp-qDJ10xND5KCLScftbhXbLC_c,1070
|
|
15
|
+
intranges-0.2.0.dist-info/RECORD,,
|
|
@@ -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.
|