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 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."""
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ intranges = intranges._cli:main
@@ -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.