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/__init__.py +44 -0
- pathseq/_arithmetic_sequence.py +154 -0
- pathseq/_ast/__init__.py +22 -0
- pathseq/_ast/_base.py +236 -0
- pathseq/_ast/_formatter.py +38 -0
- pathseq/_ast/_loose_type.py +219 -0
- pathseq/_ast/_type.py +64 -0
- pathseq/_base.py +510 -0
- pathseq/_decimal_range.py +172 -0
- pathseq/_error.py +72 -0
- pathseq/_file_num_seq.py +232 -0
- pathseq/_formatter.py +85 -0
- pathseq/_from_disk.py +73 -0
- pathseq/_loose_path_sequence.py +14 -0
- pathseq/_loose_pure_path_sequence.py +126 -0
- pathseq/_parse_file_num_seq.py +77 -0
- pathseq/_parse_loose_path_sequence.py +525 -0
- pathseq/_parse_path_sequence.py +327 -0
- pathseq/_path_sequence.py +14 -0
- pathseq/_pure_path_sequence.py +58 -0
- pathseq/py.typed +0 -0
- pathseq-0.1.0.dist-info/METADATA +134 -0
- pathseq-0.1.0.dist-info/RECORD +24 -0
- pathseq-0.1.0.dist-info/WHEEL +4 -0
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
|
+
"""
|
pathseq/_file_num_seq.py
ADDED
|
@@ -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)
|