pathseq 0.1.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.
pathseq/_error.py ADDED
@@ -0,0 +1,72 @@
1
+ class ParseError(ValueError):
2
+ """Raised when parsing a sequence string fails.
3
+
4
+ Args:
5
+ seq: The sequence string that failed parsing.
6
+ column: The index of the character of the part of the string that failed parsing.
7
+ end: The index of the last character of the part of the string that failed parsing.
8
+ reason: A human readable explanation of why parsing failed.
9
+ """
10
+
11
+ seq: str
12
+ """The sequence string that failed parsing."""
13
+ column: int
14
+ """The index of the character of the part of the string that failed parsing."""
15
+ end: int
16
+ """The index of the last character of the part of the string that failed parsing."""
17
+
18
+ def __init__(
19
+ self, seq: str, column: int, end_column: int = -1, reason: str | None = None
20
+ ):
21
+ self.seq = seq
22
+ self.column = column
23
+ self.end = end_column if end_column >= 1 else (column + 1)
24
+
25
+ lines = []
26
+ message = "Invalid sequence"
27
+ if reason:
28
+ message = f"{message}: {reason}"
29
+ lines.append(message)
30
+
31
+ prefix = " "
32
+ lines.append(f"{prefix}{seq}")
33
+ if column >= 0:
34
+ lines.append(f"{prefix}{' ' * column}{'^' * (self.end - column)}")
35
+ message = "\n".join(lines)
36
+
37
+ super().__init__(message)
38
+
39
+
40
+ class NotASequenceError(ParseError):
41
+ """Raised when parsing a string that does not represent a sequence, but a regular path.
42
+
43
+ In other words, the given sequence string has no :ref:`range <format-simple-range>`
44
+ present.
45
+ """
46
+
47
+ def __init__(self, seq: str) -> None:
48
+ super().__init__(seq, 0, len(seq) - 1, reason="No range string is present")
49
+
50
+
51
+ class IncompleteDimensionError(Exception):
52
+ """A multi-dimension sequence does not contain a consistent number of files across a dimension.
53
+
54
+ Example:
55
+ .. code-block:: pycon
56
+
57
+ >>> tmp = getfixture('tmp_path')
58
+ >>> seq = tmp / PathSequence('file.1001,1002<UDIM>_1-3#.exr')
59
+ >>> for path in seq:
60
+ ... path.touch()
61
+ ...
62
+ >>> seq.with_existing_paths()
63
+ PathSequence('.../file.1001,1002<UDIM>_1-3#.exr')
64
+ >>> (tmp / 'file.1002_3.exr').unlink()
65
+ >>> seq.with_existing_paths()
66
+ Traceback (most recent call last):
67
+ ...
68
+ pathseq._error.IncompleteDimensionError: Sequence '.../file.1001,1002<UDIM>_1-3#.exr' contains an inconsistent number of files across one or more dimensions.
69
+ >>> (tmp / 'file.1001_3.exr').unlink()
70
+ >>> seq.with_existing_paths()
71
+ PathSequence('.../file.1001,1002<UDIM>_1,2#.exr')
72
+ """
@@ -0,0 +1,232 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable, Iterator, Sequence
4
+ import decimal
5
+ import itertools
6
+ from typing import Generic, overload, TypeGuard
7
+
8
+ from typing_extensions import (
9
+ Self, # PY311
10
+ )
11
+
12
+ from ._arithmetic_sequence import ArithmeticSequence
13
+ from ._ast import FileNumT
14
+ from ._parse_file_num_seq import parse_file_num_seq
15
+
16
+
17
+ class FileNumSequenceIterator(Generic[FileNumT]):
18
+ def __init__(self, ranges: tuple[ArithmeticSequence[FileNumT], ...]):
19
+ self._ranges_iter: Iterator[ArithmeticSequence[FileNumT]] = iter(ranges)
20
+ self._range_iter: Iterator[FileNumT] = iter(())
21
+
22
+ def __iter__(self) -> Self:
23
+ return self
24
+
25
+ def __next__(self) -> FileNumT:
26
+ try:
27
+ return next(self._range_iter)
28
+ except StopIteration:
29
+ range_ = next(self._ranges_iter)
30
+ self._range_iter = iter(range_)
31
+ return next(self._range_iter)
32
+
33
+
34
+ def _seqs_from_nums(
35
+ numbers: Iterable[FileNumT],
36
+ ) -> Iterable[ArithmeticSequence[FileNumT]]:
37
+ if not numbers:
38
+ return []
39
+
40
+ numbers = iter(numbers)
41
+ seqs: list[tuple[FileNumT, FileNumT, FileNumT | None]] = []
42
+ start = next(numbers)
43
+ previous = start
44
+ step = None
45
+ current_step = None
46
+
47
+ for current in numbers:
48
+ if current == previous:
49
+ continue
50
+
51
+ current_step = current - previous
52
+
53
+ if step is None:
54
+ step = current_step
55
+
56
+ if step == current_step:
57
+ # Continue contiguous range
58
+ pass
59
+ else:
60
+ # Close off the current range
61
+ seqs.append((start, previous, step))
62
+ start = current
63
+ step = None
64
+
65
+ previous = current
66
+
67
+ # Handle the last or only range
68
+ if step is not None or current_step is None:
69
+ seqs.append((start, previous, step))
70
+
71
+ return [ArithmeticSequence(*seq) for seq in seqs]
72
+
73
+
74
+ def _consolidate_ranges(
75
+ ranges: Iterable[ArithmeticSequence[FileNumT]],
76
+ ) -> tuple[ArithmeticSequence[FileNumT], ...]:
77
+ filtered = (r for r in ranges if len(r))
78
+ try:
79
+ new_ranges = [next(filtered)]
80
+ except StopIteration:
81
+ return ()
82
+
83
+ for range_b in filtered:
84
+ range_a = new_ranges[-1]
85
+ difference = range_b.start - range_a.end
86
+ if difference == range_a.step:
87
+ # Can we merge this entire range into the previous range?
88
+ if range_a.step == range_b.step:
89
+ new_ranges[-1] = range_a.__class__(
90
+ range_a.start, range_b.end, range_a.step
91
+ )
92
+ continue
93
+
94
+ # Otherwise we can move at most one item to the previous range
95
+ # without changing the order of file numbers.
96
+ new_ranges[-1] = range_a.__class__(
97
+ range_a.start, range_b.start, range_a.step
98
+ )
99
+ range_b = range_b.__class__(
100
+ range_b.start + range_b.step, range_b.end, range_b.step
101
+ )
102
+ if not range_b:
103
+ continue
104
+ # Consolidate neighbouring numbers into a range in the hope that
105
+ # another lone number is next and can be consolidated into the range as well.
106
+ elif len(range_a) == 1 and len(range_b) == 1:
107
+ new_ranges[-1] = range_a.__class__(range_a.start, range_b.start, difference)
108
+ continue
109
+
110
+ new_ranges.append(range_b)
111
+
112
+ return tuple(new_ranges)
113
+
114
+
115
+ class FileNumSequence(Sequence[FileNumT]):
116
+ """A sequence of file numbers.
117
+
118
+ Warning:
119
+ The constructor is not considered public.
120
+ Instead, use the :meth:`~FileNumSequence.from_str`
121
+ and :meth:`~FileNumSequence.from_file_nums` class methods.
122
+ """
123
+
124
+ def __init__(self, ranges: Iterable[ArithmeticSequence[FileNumT]]) -> None:
125
+ self._ranges: tuple[ArithmeticSequence[FileNumT], ...] = _consolidate_ranges(
126
+ ranges
127
+ )
128
+
129
+ @classmethod
130
+ def from_str(
131
+ cls, seq: str
132
+ ) -> FileNumSequence[int] | FileNumSequence[decimal.Decimal]:
133
+ """Parse a range string in a file number sequence.
134
+
135
+ Args:
136
+ seq: The range string to parse (eg '1001-1005,1010-1015').
137
+
138
+ Returns:
139
+ The resulting file number sequence.
140
+ """
141
+ return cls(parse_file_num_seq(seq)) # type: ignore[arg-type]
142
+
143
+ @classmethod
144
+ def from_file_nums(cls, file_nums: Iterable[FileNumT]) -> FileNumSequence[FileNumT]:
145
+ """Create a file number sequence from an iterable of file numbers.
146
+
147
+ The given numbers are put into the sequence in the order they are provided.
148
+
149
+ Args:
150
+ file_nums: The file numbers to turn into a sequence.
151
+
152
+ Returns:
153
+ The resulting file number sequence.
154
+ """
155
+ return cls(_seqs_from_nums(file_nums))
156
+
157
+ def __contains__(self, item: object) -> bool:
158
+ """Return True if the given item is a file number in this sequence."""
159
+ if not self._ranges:
160
+ return False
161
+
162
+ return any(item in rng for rng in self._ranges)
163
+
164
+ def __iter__(self) -> Iterator[FileNumT]:
165
+ """Iterate over the file numbers in this sequence.
166
+
167
+ Yields:
168
+ Each file number in the sequence.
169
+ """
170
+ return FileNumSequenceIterator(self._ranges)
171
+
172
+ def __len__(self) -> int:
173
+ """Get the number of file numbers in this sequence."""
174
+ return sum(len(rng) for rng in self._ranges)
175
+
176
+ def __eq__(self, other: object) -> bool:
177
+ """Check for equality with another object.
178
+
179
+ File number sequences are considered equal when they contain
180
+ the same items in the same order.
181
+ """
182
+ if not isinstance(other, FileNumSequence):
183
+ return NotImplemented
184
+
185
+ return self._ranges == other._ranges
186
+
187
+ def __hash__(self) -> int:
188
+ return hash((type(self), self._ranges))
189
+
190
+ @overload
191
+ def __getitem__(self, index: int) -> FileNumT: ...
192
+
193
+ @overload
194
+ def __getitem__(self, index: slice) -> Sequence[FileNumT]: ...
195
+
196
+ def __getitem__(self, index: int | slice) -> FileNumT | Sequence[FileNumT]:
197
+ """Return one or more file numbers from this sequence.
198
+
199
+ Note:
200
+ Negative slice steps are not supported at this time.
201
+
202
+ Returns:
203
+ PurePath: When indexed with an integer.
204
+ Sequence: An immutable sequence of file numbers from this sequence
205
+ when indexed with a :class:`slice`.
206
+ """
207
+ if isinstance(index, slice):
208
+ return tuple(itertools.islice(self, *index.indices(len(self))))
209
+
210
+ for rng in self._ranges:
211
+ range_len = len(rng)
212
+ if index < range_len:
213
+ return rng[index]
214
+ index -= range_len
215
+ else:
216
+ raise IndexError(index)
217
+
218
+ def __str__(self) -> str:
219
+ return ",".join(str(rng) for rng in self._ranges)
220
+
221
+ def __repr__(self) -> str:
222
+ return f"{self.__class__.__name__}({self})"
223
+
224
+ @staticmethod
225
+ def has_subsamples(
226
+ file_num_seq: FileNumSequence[int] | FileNumSequence[decimal.Decimal],
227
+ ) -> TypeGuard[FileNumSequence[decimal.Decimal]]:
228
+ """Check whether this file number sequence contains any decimal numbers."""
229
+ if not file_num_seq:
230
+ return False
231
+
232
+ return isinstance(next(iter(file_num_seq)), decimal.Decimal)
pathseq/_formatter.py ADDED
@@ -0,0 +1,85 @@
1
+ from decimal import Decimal
2
+ import re
3
+
4
+ from ._ast import Formatter, PaddedRange
5
+
6
+
7
+ class GlobFormatter(Formatter):
8
+ """Format to a glob pattern to match paths in the given sequence."""
9
+
10
+ def __init__(self) -> None:
11
+ self._ignore_next_range = False
12
+
13
+ def range(self, range_: PaddedRange[int] | PaddedRange[Decimal]) -> str:
14
+ if self._ignore_next_range:
15
+ self._ignore_next_range = False
16
+ return ""
17
+
18
+ return "*"
19
+
20
+ def inter_range(self, inter_range: str) -> str:
21
+ if not inter_range:
22
+ self._ignore_next_range = True
23
+
24
+ return inter_range
25
+
26
+
27
+ class FileNumberFormatter(Formatter):
28
+ def __init__(self, *numbers: int | Decimal) -> None:
29
+ self._numbers = iter(numbers)
30
+
31
+ def range(self, range_: PaddedRange[int] | PaddedRange[Decimal]) -> str:
32
+ try:
33
+ number = next(self._numbers)
34
+ except StopIteration:
35
+ raise TypeError(
36
+ "Insufficient number of file numbers given to format sequence"
37
+ )
38
+
39
+ return range_.format(number)
40
+
41
+
42
+ class RegexFormatter(Formatter):
43
+ """Format to a regex pattern to match paths in the given sequence."""
44
+
45
+ def __init__(self) -> None:
46
+ self._i = 0
47
+
48
+ def stem(self, stem: str) -> str:
49
+ return re.escape(super().stem(stem))
50
+
51
+ def prefix(self, prefix: str) -> str:
52
+ return re.escape(super().prefix(prefix))
53
+
54
+ def _range(self, range_: PaddedRange[int] | PaddedRange[Decimal]) -> str:
55
+ if range_.pad_format == "<UVTILE>":
56
+ return r"u\d+_v\d+"
57
+
58
+ pad_format = range_.pad_format
59
+ if range_.pad_format == "<UDIM>":
60
+ pad_format = "####"
61
+
62
+ if "." in pad_format:
63
+ head, tail = pad_format.split(".", 1)
64
+ tail_re = r"\.[0-9]*" + r"[0-9]" * len(tail)
65
+ else:
66
+ head = pad_format
67
+ tail_re = ""
68
+ if range_.has_subsamples(range_):
69
+ tail_re = r"(\.[0-9]+)?"
70
+
71
+ positive_re = r"([1-9][0-9]*)?" + r"[0-9]" * len(head)
72
+ negative_re = r"-([1-9][0-9]*)?" + r"[0-9]" * (len(head) - 1)
73
+
74
+ return f"({positive_re}|{negative_re}){tail_re}"
75
+
76
+ def range(self, range_: PaddedRange[int] | PaddedRange[Decimal]) -> str:
77
+ result = f"(?P<range{self._i}>{self._range(range_)})"
78
+ self._i += 1
79
+ return result
80
+
81
+ def inter_range(self, inter_range: str) -> str:
82
+ return re.escape(super().inter_range(inter_range))
83
+
84
+ def suffixes(self, suffixes: tuple[str, ...]) -> str:
85
+ return re.escape(super().suffixes(suffixes))
pathseq/_from_disk.py ADDED
@@ -0,0 +1,73 @@
1
+ from collections.abc import Iterator
2
+ from decimal import Decimal
3
+ import functools
4
+ import operator
5
+ import pathlib
6
+ import re
7
+
8
+ from ._ast import (
9
+ RangesStartName,
10
+ RangesInName,
11
+ RangesEndName,
12
+ ParsedSequence,
13
+ )
14
+ from ._error import IncompleteDimensionError
15
+ from ._file_num_seq import FileNumSequence
16
+ from ._formatter import GlobFormatter, RegexFormatter
17
+
18
+
19
+ def find_on_disk(
20
+ path: pathlib.Path,
21
+ parsed: ParsedSequence | RangesStartName | RangesInName | RangesEndName,
22
+ ) -> Iterator[FileNumSequence[int] | FileNumSequence[Decimal]]:
23
+ """Find the ranges of files that exist on disk for the given path.
24
+
25
+ Each file number sequence in the path sequence will be ordered numerically.
26
+
27
+ Raises:
28
+ IncompleteDimensionError: When one or more dimensions in
29
+ a multi-dimension sequence does not have a consistent number of
30
+ files in each other dimension.
31
+ """
32
+ num_ranges = len(parsed.ranges.ranges)
33
+ file_str_sets: list[set[str]] = [set() for _ in range(num_ranges)]
34
+ glob_pattern = GlobFormatter().format(parsed)
35
+ paths = path.parent.glob(glob_pattern)
36
+ pattern = re.compile(RegexFormatter().format(parsed))
37
+ num_paths = 0
38
+ for found in paths:
39
+ match = pattern.fullmatch(str(found.name))
40
+ if not match:
41
+ continue
42
+
43
+ file_nums = []
44
+ for i in range(num_ranges):
45
+ group_name = f"range{i}"
46
+ group = match.group(group_name)
47
+ file_nums.append(group)
48
+
49
+ if len(file_nums) != num_ranges:
50
+ continue
51
+
52
+ num_paths += 1
53
+ for file_num, file_str_set in zip(file_nums, file_str_sets):
54
+ file_str_set.add(file_num)
55
+
56
+ expected = functools.reduce(operator.mul, (len(nums) for nums in file_str_sets), 1)
57
+ if num_paths != expected:
58
+ raise IncompleteDimensionError(
59
+ f"Sequence '{path}' contains an inconsistent number of files across one or more dimensions."
60
+ )
61
+
62
+ for file_str_set in file_str_sets:
63
+ file_num_seq: FileNumSequence[int] | FileNumSequence[Decimal]
64
+ if any("." in file_str for file_str in file_str_set):
65
+ file_num_seq = FileNumSequence.from_file_nums(
66
+ sorted(Decimal(file_str) for file_str in file_str_set)
67
+ )
68
+ else:
69
+ file_num_seq = FileNumSequence.from_file_nums(
70
+ sorted(int(file_str) for file_str in file_str_set)
71
+ )
72
+
73
+ yield file_num_seq
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from ._base import BasePathSequence, PathT_co
4
+ from ._loose_pure_path_sequence import LoosePurePathSequence
5
+
6
+
7
+ class LoosePathSequence(LoosePurePathSequence[PathT_co], BasePathSequence[PathT_co]):
8
+ """A sequence of Path objects.
9
+
10
+ Raises:
11
+ NotASequenceError: When the given path does not represent a sequence,
12
+ but a regular path.
13
+ ParseError: When the given path is not a valid path sequence.
14
+ """
@@ -0,0 +1,126 @@
1
+ from __future__ import annotations
2
+
3
+ from decimal import Decimal
4
+
5
+ from typing_extensions import (
6
+ Self, # PY311
7
+ )
8
+
9
+ from ._ast import PaddedRange, ParsedLooseSequence, Ranges
10
+ from ._base import BasePurePathSequence, PurePathT_co
11
+ from ._error import ParseError
12
+ from ._file_num_seq import FileNumSequence
13
+ from ._parse_loose_path_sequence import parse_path_sequence
14
+
15
+
16
+ class LoosePurePathSequence(BasePurePathSequence[PurePathT_co]):
17
+ """A sequence of PurePath objects.
18
+
19
+ Raises:
20
+ NotASequenceError: When the given path does not represent a sequence,
21
+ but a regular path.
22
+ ParseError: When the given path is not a valid path sequence.
23
+ """
24
+
25
+ _parsed: ParsedLooseSequence
26
+
27
+ def _parse(self, name: str) -> ParsedLooseSequence:
28
+ return parse_path_sequence(name)
29
+
30
+ @property
31
+ def parsed(self) -> ParsedLooseSequence:
32
+ """The parsed sequence string, as a tree of objects."""
33
+ return self._parsed
34
+
35
+ @property
36
+ def suffix(self) -> str:
37
+ """The file extension of the paths in the sequence.
38
+
39
+ If there is no suffix, this will be the empty string.
40
+
41
+ .. code-block:: pycon
42
+
43
+ >>> LoosePurePathSequence('file1-3###').suffix
44
+ ''
45
+ """
46
+ return super().suffix
47
+
48
+ def with_suffix(self, suffix: str) -> Self:
49
+ """Return a new path sequence with the suffix changed.
50
+
51
+ If the given suffix is the empty string, the existing suffix will be removed.
52
+ But if the path sequence has only one suffix,
53
+ then a :exc:`ValueError` will be raised because removing the suffix will result in
54
+ an invalid path sequence.
55
+
56
+ Args:
57
+ suffix: The new suffix to replace the existing suffix with.
58
+
59
+ Raises:
60
+ ValueError: If the given suffix would result in an invalid path sequence.
61
+ """
62
+ parsed = self._parsed.with_suffix(suffix)
63
+ try:
64
+ return self.with_segments(self._path.parent, str(parsed))
65
+ except ParseError:
66
+ raise ValueError(
67
+ f"Cannot use suffix '{suffix}' because"
68
+ " it would result in an invalid path sequence"
69
+ )
70
+
71
+ @property
72
+ def stem(self) -> str:
73
+ """The final path component, without any prefix, ranges, postfix, or suffixes.
74
+
75
+ .. code-block:: pycon
76
+
77
+ >>> LoosePurePathSequence('/path/to/images.1-3####.exr').stem
78
+ 'images'
79
+ >>> LoosePurePathSequence('/path/to/1-3####_images.exr').stem
80
+ 'images'
81
+ >>> LoosePurePathSequence('/path/to/images.exr.1-3####').stem
82
+ 'images'
83
+
84
+ Unlike :attr:`pathlib.PurePath.stem`, this will never contain a suffix
85
+ if the paths have multiple suffixes:
86
+
87
+ .. code-block:: pycon
88
+
89
+ >>> LoosePurePathSequence('/path/to/images.tar.gz.1-3####').stem
90
+ 'images'
91
+
92
+ If the paths have no stem, then the empty string is returned:
93
+
94
+ .. code-block:: pycon
95
+
96
+ >>> LoosePurePathSequence('1-3#.tar.gz').stem
97
+ ''
98
+ """
99
+ return super().stem
100
+
101
+ def with_file_num_seqs(
102
+ self, *seqs: FileNumSequence[int] | FileNumSequence[Decimal]
103
+ ) -> Self:
104
+ """Return a new sequence with the file number sequences changed.
105
+
106
+ Raises:
107
+ TypeError: If the given number of file number sequences does not match
108
+ the sequence's number of file number sequences.
109
+ """
110
+ if len(seqs) != len(self._parsed.ranges.ranges):
111
+ raise TypeError(
112
+ f"Need {len(self._parsed.ranges.ranges)} sequences, but got {len(seqs)}"
113
+ )
114
+
115
+ new_ranges = tuple(
116
+ PaddedRange(seq, range_.pad_format)
117
+ for seq, range_ in zip(seqs, self._parsed.ranges.ranges)
118
+ )
119
+ new = self._parsed.__class__(
120
+ stem=self._parsed.stem,
121
+ prefix=self._parsed.prefix, # type: ignore[arg-type]
122
+ ranges=Ranges(new_ranges, self._parsed.ranges.inter_ranges),
123
+ postfix=self._parsed.postfix, # type: ignore[arg-type]
124
+ suffixes=self._parsed.suffixes,
125
+ )
126
+ return self.__class__(self._path.with_name(str(new)))
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ from decimal import Decimal as D
4
+ from typing import TypeAlias, TypeVar, Union
5
+
6
+ import lark
7
+
8
+ from ._arithmetic_sequence import ArithmeticSequence
9
+ from ._error import ParseError
10
+
11
+ _GRAMMAR = r"""
12
+ start: ranges
13
+
14
+ ranges: range ("," range)*
15
+
16
+ range: FILE_NUM ["-" FILE_NUM ["x" NUM]]
17
+
18
+ FILE_NUM: "-"? NUM
19
+ NUM: /
20
+ (0|[1-9][0-9]*) # the digits
21
+ (\.0|\.[0-9]*[1-9])? # the subsamples
22
+ /x
23
+ """
24
+ _PARSER = lark.Lark(_GRAMMAR, parser="lalr")
25
+ _SeqArgsT: TypeAlias = Union[tuple[str], tuple[str, str], tuple[str, str, str]]
26
+ T = TypeVar("T")
27
+
28
+
29
+ @lark.v_args(inline=True)
30
+ class _RangeReducer(
31
+ lark.Transformer[
32
+ lark.Token, list[ArithmeticSequence[int]] | list[ArithmeticSequence[D]]
33
+ ]
34
+ ):
35
+ def start(self, seq: T) -> T:
36
+ return seq
37
+
38
+ def ranges(
39
+ self, range_: _SeqArgsT, *ranges: _SeqArgsT
40
+ ) -> list[ArithmeticSequence[int]] | list[ArithmeticSequence[D]]:
41
+ if any("." in arg for arg in range_) or any(
42
+ "." in arg for r in ranges for arg in r
43
+ ):
44
+ return [
45
+ ArithmeticSequence(*tuple(D(arg) for arg in range_)),
46
+ *(ArithmeticSequence(*tuple(D(arg) for arg in r)) for r in ranges),
47
+ ]
48
+
49
+ return [
50
+ ArithmeticSequence(*tuple(int(arg) for arg in range_)),
51
+ *(ArithmeticSequence(*tuple(int(arg) for arg in r)) for r in ranges),
52
+ ]
53
+
54
+ def range(self, start: str, end: str | None, step: str | None) -> _SeqArgsT:
55
+ assert step is None or end is not None, "Parsed an end but no step"
56
+
57
+ args: _SeqArgsT = (start,)
58
+ if end is not None:
59
+ args = (start, end)
60
+ if step is not None:
61
+ args = (start, end, step)
62
+
63
+ return args
64
+
65
+
66
+ def parse_file_num_seq(
67
+ seq: str,
68
+ ) -> list[ArithmeticSequence[int]] | list[ArithmeticSequence[D]]:
69
+ if not seq:
70
+ return []
71
+
72
+ try:
73
+ ranges = _PARSER.parse(seq)
74
+ except lark.UnexpectedInput as exc:
75
+ raise ParseError(seq, exc.column)
76
+
77
+ return _RangeReducer().transform(ranges)