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/_base.py
ADDED
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import abc
|
|
4
|
+
from collections.abc import Iterator, Sequence
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
import itertools
|
|
7
|
+
import os
|
|
8
|
+
import pathlib
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
from typing import ClassVar, overload, TypeVar, Union
|
|
12
|
+
|
|
13
|
+
from typing_extensions import (
|
|
14
|
+
Self, # PY311
|
|
15
|
+
TypeAlias, # PY310
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from ._ast import (
|
|
19
|
+
ParsedLooseSequence,
|
|
20
|
+
ParsedSequence,
|
|
21
|
+
)
|
|
22
|
+
from ._error import ParseError
|
|
23
|
+
from ._file_num_seq import FileNumSequence
|
|
24
|
+
from ._from_disk import find_on_disk
|
|
25
|
+
from ._formatter import FileNumberFormatter, RegexFormatter
|
|
26
|
+
|
|
27
|
+
Segment: TypeAlias = Union[str, os.PathLike[str]]
|
|
28
|
+
PurePathT_co = TypeVar(
|
|
29
|
+
"PurePathT_co",
|
|
30
|
+
covariant=True,
|
|
31
|
+
bound=pathlib.PurePath,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class BasePurePathSequence(Sequence[PurePathT_co], metaclass=abc.ABCMeta):
|
|
36
|
+
"""A generic class that represents a path sequence.
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
NotASequenceError: When the given path does not represent a sequence.
|
|
40
|
+
ParseError: When the given path is not a valid path sequence.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
_pathlib_type: ClassVar[type[pathlib.PurePath]] = pathlib.PurePath
|
|
44
|
+
_path: PurePathT_co
|
|
45
|
+
_parsed: ParsedSequence | ParsedLooseSequence
|
|
46
|
+
|
|
47
|
+
@overload
|
|
48
|
+
def __init__(self: BasePurePathSequence[pathlib.PurePath], path: str) -> None: ...
|
|
49
|
+
|
|
50
|
+
@overload
|
|
51
|
+
def __init__(
|
|
52
|
+
self: BasePurePathSequence[PurePathT_co], path: PurePathT_co
|
|
53
|
+
) -> None: ...
|
|
54
|
+
|
|
55
|
+
def __init__(self, path: PurePathT_co | str) -> None:
|
|
56
|
+
if isinstance(path, str):
|
|
57
|
+
self._path = self._pathlib_type(path) # type: ignore[assignment]
|
|
58
|
+
else:
|
|
59
|
+
self._path = path
|
|
60
|
+
self._parsed = self._parse(self._path.name)
|
|
61
|
+
|
|
62
|
+
@abc.abstractmethod
|
|
63
|
+
def _parse(self, name: str) -> ParsedSequence | ParsedLooseSequence:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
# General properties
|
|
67
|
+
def __eq__(self, other: object) -> bool:
|
|
68
|
+
"""Check for equality with another object.
|
|
69
|
+
|
|
70
|
+
Two path sequences are considered equal if they contain the same paths
|
|
71
|
+
in the same order, even if their string representation differs:
|
|
72
|
+
|
|
73
|
+
.. code-block:: pycon
|
|
74
|
+
|
|
75
|
+
>>> seq_a = PathSequence("/path/to/image.1-3####.exr")
|
|
76
|
+
>>> seq_b = PathSequence("/path/to/image.1,2,3####.exr")
|
|
77
|
+
>>> seq_a == seq_b
|
|
78
|
+
True
|
|
79
|
+
"""
|
|
80
|
+
if not isinstance(other, self.__class__):
|
|
81
|
+
return NotImplemented
|
|
82
|
+
|
|
83
|
+
return self._path.parent == other._path.parent and self._parsed == other._parsed
|
|
84
|
+
|
|
85
|
+
def __repr__(self) -> str:
|
|
86
|
+
return "{}({!r})".format(self.__class__.__name__, self.as_posix())
|
|
87
|
+
|
|
88
|
+
def __str__(self) -> str:
|
|
89
|
+
return str(self._path)
|
|
90
|
+
|
|
91
|
+
def __hash__(self) -> int:
|
|
92
|
+
"""Path sequences are immutable, so can be hashed and used as dictionary keys."""
|
|
93
|
+
return hash((type(self), self._path.parent, self._parsed))
|
|
94
|
+
|
|
95
|
+
# Path operations
|
|
96
|
+
def __rtruediv__(self, key: Segment) -> Self:
|
|
97
|
+
try:
|
|
98
|
+
return self.with_segments(key, self._path)
|
|
99
|
+
except TypeError:
|
|
100
|
+
return NotImplemented
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def parts(self) -> tuple[str, ...]:
|
|
104
|
+
"""A tuple giving access to the sequence's various components.
|
|
105
|
+
|
|
106
|
+
.. code-block:: pycon
|
|
107
|
+
|
|
108
|
+
>>> s = PathSequence('/path/to/image.1-3####.exr')
|
|
109
|
+
>>> s.parts
|
|
110
|
+
('/', 'path', 'to', 'image.1-3####.exr')
|
|
111
|
+
"""
|
|
112
|
+
return self._path.parts
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def name(self) -> str:
|
|
116
|
+
"""A string representing the final path component, excluding the drive and root.
|
|
117
|
+
|
|
118
|
+
Given that a sequence's final path component must be the sequence's files,
|
|
119
|
+
the name will always be a valid sequence string.
|
|
120
|
+
|
|
121
|
+
.. code-block:: pycon
|
|
122
|
+
|
|
123
|
+
>>> PurePathSequence('/path/to/image.1-3####.exr').name
|
|
124
|
+
'image.1-3####.exr'
|
|
125
|
+
"""
|
|
126
|
+
return self._path.name
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def stem(self) -> str:
|
|
130
|
+
"""The name, without any ranges, suffixes, or range strings.
|
|
131
|
+
|
|
132
|
+
.. code-block:: pycon
|
|
133
|
+
|
|
134
|
+
>>> PurePathSequence('/path/to/images.1-3####.exr').stem
|
|
135
|
+
'images'
|
|
136
|
+
"""
|
|
137
|
+
return self._parsed.stem
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def file_num_seqs(
|
|
141
|
+
self,
|
|
142
|
+
) -> Sequence[FileNumSequence[int] | FileNumSequence[Decimal]]:
|
|
143
|
+
"""All file number sequences in the name.
|
|
144
|
+
|
|
145
|
+
.. code-block:: pycon
|
|
146
|
+
|
|
147
|
+
>>> PurePathSequence('/path/to/texture.1011-1013<UDIM>_1-3#.tex').file_num_seqs
|
|
148
|
+
(FileNumSequence(1011-1013), FileNumSequence(1-3))
|
|
149
|
+
"""
|
|
150
|
+
ranges = tuple(
|
|
151
|
+
x.file_nums
|
|
152
|
+
for x in self._parsed.ranges.ranges
|
|
153
|
+
if not isinstance(x.file_nums, str)
|
|
154
|
+
)
|
|
155
|
+
if len(ranges) != len(self._parsed.ranges.ranges):
|
|
156
|
+
raise TypeError(
|
|
157
|
+
"Cannot get the file number sequences of a path sequence with incomplete ranges."
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
return ranges
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def suffix(self) -> str:
|
|
164
|
+
"""The file extension of the paths in the sequence.
|
|
165
|
+
|
|
166
|
+
A simple path sequence will always have a file extension.
|
|
167
|
+
Therefore this will never return the empty string.
|
|
168
|
+
|
|
169
|
+
.. code-block:: pycon
|
|
170
|
+
|
|
171
|
+
>>> PurePathSequence('/path/to/image.1-3####.exr').suffix
|
|
172
|
+
'.exr'
|
|
173
|
+
"""
|
|
174
|
+
suffixes = self.suffixes
|
|
175
|
+
if not suffixes:
|
|
176
|
+
return ""
|
|
177
|
+
|
|
178
|
+
return suffixes[-1]
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def suffixes(self) -> Sequence[str]:
|
|
182
|
+
"""A list of the file extensions of any path in the sequence.
|
|
183
|
+
|
|
184
|
+
.. code-block:: pycon
|
|
185
|
+
|
|
186
|
+
>>> PurePathSequence('/path/to/file.1-3####.tar.gz').suffixes
|
|
187
|
+
('.tar', '.gz')
|
|
188
|
+
"""
|
|
189
|
+
return self._parsed.suffixes
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def parents(self) -> Sequence[PurePathT_co]:
|
|
193
|
+
"""An immutable sequence providing access to the logical ancestors of the sequence.
|
|
194
|
+
|
|
195
|
+
.. code-block:: pycon
|
|
196
|
+
|
|
197
|
+
>>> s = PurePathSequence(PureWindowsPath('c:/path/to/images.1-3####.exr'))
|
|
198
|
+
>>> s.parents[0]
|
|
199
|
+
PureWindowsPath('c:/path/to')
|
|
200
|
+
>>> s.parents[1]
|
|
201
|
+
PureWindowsPath('c:/path')
|
|
202
|
+
>>> s.parents[2]
|
|
203
|
+
PureWindowsPath('c:/')
|
|
204
|
+
"""
|
|
205
|
+
return self._path.parents
|
|
206
|
+
|
|
207
|
+
@property
|
|
208
|
+
def parent(self) -> pathlib.PurePath:
|
|
209
|
+
"""The logical parent of the sequence.
|
|
210
|
+
|
|
211
|
+
.. code-block:: pycon
|
|
212
|
+
|
|
213
|
+
>>> s = PurePathSequence(PurePosixPath('/a/b/c/d.1-3#.exr'))
|
|
214
|
+
>>> s.parent
|
|
215
|
+
PurePosixPath('/a/b/c')
|
|
216
|
+
"""
|
|
217
|
+
return self._path.parent
|
|
218
|
+
|
|
219
|
+
def as_posix(self) -> str:
|
|
220
|
+
r"""Return a string representation of the sequence with forward slashes (/).
|
|
221
|
+
|
|
222
|
+
.. code-block:: pycon
|
|
223
|
+
|
|
224
|
+
>>> s = PurePathSequence(PureWindowsPath('c:\\windows\\images.1-3#.exr'))
|
|
225
|
+
>>> str(s)
|
|
226
|
+
'c:\\windows\\images.1-3#.exr'
|
|
227
|
+
>>> s.as_posix()
|
|
228
|
+
'c:/windows/images.1-3#.exr'
|
|
229
|
+
"""
|
|
230
|
+
return self._path.as_posix()
|
|
231
|
+
|
|
232
|
+
def is_relative_to(self, other: pathlib.PurePath) -> bool:
|
|
233
|
+
"""Return whether or not all files in this sequence are relative to the other path.
|
|
234
|
+
|
|
235
|
+
Raises:
|
|
236
|
+
ValueError: If this path sequence is given.
|
|
237
|
+
The paths in a sequence cannot be relative to a sequence.
|
|
238
|
+
"""
|
|
239
|
+
if self._path == other:
|
|
240
|
+
raise ValueError("Cannot check if a path sequence is relative to itself")
|
|
241
|
+
|
|
242
|
+
return self._path.is_relative_to(other) and not self._path == other
|
|
243
|
+
|
|
244
|
+
if sys.version_info >= (3, 12):
|
|
245
|
+
|
|
246
|
+
def relative_to(self, other: Segment, *, walk_up: bool = False) -> Self:
|
|
247
|
+
"""Compute a version of this path relative to the path represented by ``other``.
|
|
248
|
+
|
|
249
|
+
.. note::
|
|
250
|
+
|
|
251
|
+
`walk_up` is available only in Python 3.12 and newer.
|
|
252
|
+
|
|
253
|
+
Raises:
|
|
254
|
+
ValueError: When this sequence cannot be relative to the given path.
|
|
255
|
+
"""
|
|
256
|
+
if other == str(self):
|
|
257
|
+
raise ValueError("Cannot make a path sequence relative to itself")
|
|
258
|
+
|
|
259
|
+
return self.with_segments(self._path.relative_to(other, walk_up=walk_up))
|
|
260
|
+
else:
|
|
261
|
+
|
|
262
|
+
def relative_to(self, other: Segment) -> Self:
|
|
263
|
+
"""Compute a version of this path relative to the path represented by ``other``.
|
|
264
|
+
|
|
265
|
+
Raises:
|
|
266
|
+
ValueError: When this sequence cannot be relative to the given path.
|
|
267
|
+
"""
|
|
268
|
+
if other == str(self):
|
|
269
|
+
raise ValueError("Cannot make a path sequence relative to itself")
|
|
270
|
+
|
|
271
|
+
return type(self)(self._path.relative_to(other))
|
|
272
|
+
|
|
273
|
+
def with_name(self, name: str) -> Self:
|
|
274
|
+
"""Return a new sequence with the :attr:`~.name` changed.
|
|
275
|
+
|
|
276
|
+
Given that a sequence's final path component must be the sequence's files,
|
|
277
|
+
the given name must also be a valid sequence string.
|
|
278
|
+
|
|
279
|
+
Raises:
|
|
280
|
+
ValueError: When the given name is empty.
|
|
281
|
+
Use :attr:`~.BasePurePathSequence.parent` instead.
|
|
282
|
+
NotASequenceError: When the given name does not represent a sequence.
|
|
283
|
+
ParseError: When the resulting path is not a valid path sequence.
|
|
284
|
+
"""
|
|
285
|
+
return self.with_segments(self._path.with_name(name))
|
|
286
|
+
|
|
287
|
+
def with_stem(self, stem: str) -> Self:
|
|
288
|
+
"""Return a new path with the :attr:`~.stem` changed.
|
|
289
|
+
|
|
290
|
+
.. code-block:: pycon
|
|
291
|
+
|
|
292
|
+
>>> p = PurePathSequence('images.1-3#.exr')
|
|
293
|
+
>>> p.with_stem('textures')
|
|
294
|
+
PurePathSequence('textures.1-3#.exr')
|
|
295
|
+
|
|
296
|
+
Raises:
|
|
297
|
+
ValueError: If the new stem is invalid.
|
|
298
|
+
"""
|
|
299
|
+
parsed = self._parsed.with_stem(stem)
|
|
300
|
+
return self.with_segments(self._path.parent, str(parsed))
|
|
301
|
+
|
|
302
|
+
@abc.abstractmethod
|
|
303
|
+
def with_file_num_seqs(
|
|
304
|
+
self, *seqs: FileNumSequence[int] | FileNumSequence[Decimal]
|
|
305
|
+
) -> Self:
|
|
306
|
+
"""Return a new sequence with the file number sequences changed.
|
|
307
|
+
|
|
308
|
+
Raises:
|
|
309
|
+
TypeError: If the given number of file number sequences does not match
|
|
310
|
+
the sequence's number of file number sequences.
|
|
311
|
+
"""
|
|
312
|
+
|
|
313
|
+
def path_with_file_nums(self, *numbers: int | Decimal) -> PurePathT_co:
|
|
314
|
+
"""Return a path for the given file number(s) in the sequence.
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
numbers: The number to use in place of each range in the string.
|
|
318
|
+
|
|
319
|
+
.. code-block:: pycon
|
|
320
|
+
|
|
321
|
+
>>> p = PurePathSequence('images.1-3#.exr')
|
|
322
|
+
>>> p.path_with_file_nums(5)
|
|
323
|
+
PurePosixPath('images.5.exr')
|
|
324
|
+
"""
|
|
325
|
+
name = FileNumberFormatter(*numbers).format(self._parsed)
|
|
326
|
+
return self._path.with_name(name)
|
|
327
|
+
|
|
328
|
+
def with_suffix(self, suffix: str) -> Self:
|
|
329
|
+
"""Return a new path sequence with the suffix changed.
|
|
330
|
+
|
|
331
|
+
If the given suffix is the empty string, the existing suffix will be removed.
|
|
332
|
+
But if the path sequence has only one suffix,
|
|
333
|
+
then a :exc:`ValueError` will be raised because removing the suffix will result in
|
|
334
|
+
an invalid path sequence.
|
|
335
|
+
|
|
336
|
+
Args:
|
|
337
|
+
suffix: The new suffix to replace the existing suffix with.
|
|
338
|
+
|
|
339
|
+
Raises:
|
|
340
|
+
ValueError: If the given suffix would result in an invalid path sequence.
|
|
341
|
+
"""
|
|
342
|
+
if not suffix:
|
|
343
|
+
raise ValueError("Simple path format must have a suffix")
|
|
344
|
+
|
|
345
|
+
parsed = self._parsed.with_suffix(suffix)
|
|
346
|
+
try:
|
|
347
|
+
return self.with_segments(self._path.parent, str(parsed))
|
|
348
|
+
except ParseError:
|
|
349
|
+
raise ValueError(
|
|
350
|
+
f"Cannot use suffix '{suffix}' because"
|
|
351
|
+
" it would result in an invalid path sequence"
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
def with_segments(self, *pathsegments: Segment) -> Self:
|
|
355
|
+
"""Create a new sequence object of the same type by combining the given `pathsegments`."""
|
|
356
|
+
return type(self)(self._path.__class__(*pathsegments)) # type: ignore[abstract]
|
|
357
|
+
|
|
358
|
+
@overload
|
|
359
|
+
def __getitem__(self, index: int) -> PurePathT_co: ...
|
|
360
|
+
|
|
361
|
+
@overload
|
|
362
|
+
def __getitem__(self, index: slice) -> Sequence[PurePathT_co]: ...
|
|
363
|
+
|
|
364
|
+
def __getitem__(self, index: int | slice) -> PurePathT_co | Sequence[PurePathT_co]:
|
|
365
|
+
"""Return one or more paths from this path sequence.
|
|
366
|
+
|
|
367
|
+
Note:
|
|
368
|
+
Negative slice steps are not supported at this time.
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
PurePath: When indexed with an integer.
|
|
372
|
+
Sequence: An immutable sequence of paths from this path sequence
|
|
373
|
+
when indexed with a :class:`slice`.
|
|
374
|
+
"""
|
|
375
|
+
if isinstance(index, slice):
|
|
376
|
+
return tuple(itertools.islice(self, *index.indices(len(self))))
|
|
377
|
+
|
|
378
|
+
if not self:
|
|
379
|
+
raise IndexError("Path sequence is empty so index is out of range")
|
|
380
|
+
|
|
381
|
+
file_num_seqs = [x.file_nums for x in self._parsed.ranges.ranges]
|
|
382
|
+
start = index
|
|
383
|
+
if start < 0:
|
|
384
|
+
start += len(self)
|
|
385
|
+
if start < 0:
|
|
386
|
+
raise IndexError("index out of range")
|
|
387
|
+
elif start > len(self):
|
|
388
|
+
raise IndexError("index out of range")
|
|
389
|
+
|
|
390
|
+
indexes = [0 for _ in file_num_seqs]
|
|
391
|
+
for i, file_num_seq in enumerate(reversed(file_num_seqs), start=1):
|
|
392
|
+
next_idx = start % len(file_num_seq)
|
|
393
|
+
indexes[-i] = next_idx
|
|
394
|
+
start -= next_idx
|
|
395
|
+
|
|
396
|
+
assert start == 0
|
|
397
|
+
file_nums = [
|
|
398
|
+
x.file_nums[i]
|
|
399
|
+
for x, i in zip(self._parsed.ranges.ranges, reversed(indexes))
|
|
400
|
+
]
|
|
401
|
+
return self.path_with_file_nums(*file_nums)
|
|
402
|
+
|
|
403
|
+
def __contains__(self, item: object) -> bool:
|
|
404
|
+
"""Return whether the given object exists in this path sequence."""
|
|
405
|
+
if not isinstance(item, self._pathlib_type):
|
|
406
|
+
return False
|
|
407
|
+
|
|
408
|
+
pattern = re.compile(RegexFormatter().format(self._parsed))
|
|
409
|
+
|
|
410
|
+
match = pattern.fullmatch(str(item.name))
|
|
411
|
+
if not match:
|
|
412
|
+
return False
|
|
413
|
+
|
|
414
|
+
for i, range_ in enumerate(self._parsed.ranges.ranges):
|
|
415
|
+
group_name = f"range{i}"
|
|
416
|
+
group = match.group(group_name)
|
|
417
|
+
assert isinstance(group, str), "Got an unexpected type from regex group"
|
|
418
|
+
|
|
419
|
+
file_num_seq = range_.file_nums
|
|
420
|
+
if FileNumSequence.has_subsamples(file_num_seq):
|
|
421
|
+
if Decimal(group) not in file_num_seq:
|
|
422
|
+
return False
|
|
423
|
+
else:
|
|
424
|
+
if int(group) not in file_num_seq:
|
|
425
|
+
return False
|
|
426
|
+
|
|
427
|
+
return True
|
|
428
|
+
|
|
429
|
+
def __iter__(self) -> Iterator[PurePathT_co]:
|
|
430
|
+
"""Iterate over the paths in this sequence."""
|
|
431
|
+
iterators = (iter(x) for x in self.file_num_seqs)
|
|
432
|
+
# TODO: Swap this out for manual looping so that are aren't using mega amounts of memory
|
|
433
|
+
for result in itertools.product(*iterators):
|
|
434
|
+
# https://github.com/python/typeshed/issues/13490
|
|
435
|
+
yield self.path_with_file_nums(*result) # type: ignore[arg-type]
|
|
436
|
+
|
|
437
|
+
def __len__(self) -> int:
|
|
438
|
+
"""Return the length of this sequence.
|
|
439
|
+
|
|
440
|
+
If the sequence has any empty ranges, then its length is zero.
|
|
441
|
+
|
|
442
|
+
.. code-block:: pycon
|
|
443
|
+
|
|
444
|
+
>>> len(PurePathSequence('images.####.exr'))
|
|
445
|
+
0
|
|
446
|
+
"""
|
|
447
|
+
result = 1
|
|
448
|
+
|
|
449
|
+
for x in self._parsed.ranges.ranges:
|
|
450
|
+
result *= len(x.file_nums)
|
|
451
|
+
|
|
452
|
+
return result
|
|
453
|
+
|
|
454
|
+
def has_subsamples(self) -> bool:
|
|
455
|
+
"""Check whether this path sequence contains any decimal file numbers."""
|
|
456
|
+
return any(r.has_subsamples(r) for r in self._parsed.ranges.ranges)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
PathT_co = TypeVar("PathT_co", covariant=True, bound=pathlib.Path)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
class BasePathSequence(BasePurePathSequence[PathT_co], metaclass=abc.ABCMeta):
|
|
463
|
+
"""A sequence of Path objects.
|
|
464
|
+
|
|
465
|
+
Raises:
|
|
466
|
+
NotASequenceError: When the given path does not represent a sequence,
|
|
467
|
+
but a regular path.
|
|
468
|
+
ParseError: When the given path is not a valid path sequence.
|
|
469
|
+
"""
|
|
470
|
+
|
|
471
|
+
_pathlib_type: ClassVar[type[pathlib.Path]] = pathlib.Path
|
|
472
|
+
|
|
473
|
+
@overload
|
|
474
|
+
def __init__(self: BasePathSequence[pathlib.Path], path: str) -> None: ...
|
|
475
|
+
|
|
476
|
+
@overload
|
|
477
|
+
def __init__(self: BasePathSequence[PathT_co], path: PathT_co) -> None: ...
|
|
478
|
+
|
|
479
|
+
def __init__(self, path: PathT_co | str) -> None:
|
|
480
|
+
super().__init__(path)
|
|
481
|
+
|
|
482
|
+
def expanduser(self) -> Self:
|
|
483
|
+
"""Return a new sequence with expanded ``~`` and ``~user`` constructs.
|
|
484
|
+
|
|
485
|
+
The expansion will be as returned by :func:`os.path.expanduser`.
|
|
486
|
+
|
|
487
|
+
Raises:
|
|
488
|
+
RuntimeError: If a home directory can't be resolved.
|
|
489
|
+
"""
|
|
490
|
+
return self.with_segments(self._path.expanduser())
|
|
491
|
+
|
|
492
|
+
def absolute(self) -> Self:
|
|
493
|
+
"""Return a new sequence with the sequence's paths made absolute.
|
|
494
|
+
|
|
495
|
+
Normalisation and symlink resolution are not performed.
|
|
496
|
+
"""
|
|
497
|
+
return self.with_segments(self._path.absolute())
|
|
498
|
+
|
|
499
|
+
def with_existing_paths(self) -> Self:
|
|
500
|
+
"""Return a new a sequence using the ranges of files that exist on disk.
|
|
501
|
+
|
|
502
|
+
Each file number sequence in the path sequence will be ordered numerically.
|
|
503
|
+
|
|
504
|
+
Raises:
|
|
505
|
+
IncompleteDimensionError: When one or more dimensions in
|
|
506
|
+
a multi-dimension sequence does not have a consistent number of
|
|
507
|
+
files in each other dimension.
|
|
508
|
+
"""
|
|
509
|
+
seqs = find_on_disk(self._path, self._parsed)
|
|
510
|
+
return self.with_file_num_seqs(*seqs)
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
import decimal
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
from typing_extensions import Self # PY311
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DecimalRangeIterator:
|
|
11
|
+
def __init__(
|
|
12
|
+
self, start: decimal.Decimal, stop: decimal.Decimal, step: decimal.Decimal
|
|
13
|
+
) -> None:
|
|
14
|
+
self._current = start
|
|
15
|
+
self._stop = stop
|
|
16
|
+
self._step = step
|
|
17
|
+
|
|
18
|
+
def __iter__(self) -> Self:
|
|
19
|
+
return self
|
|
20
|
+
|
|
21
|
+
def __next__(self) -> decimal.Decimal:
|
|
22
|
+
if self._step > 0:
|
|
23
|
+
if self._current >= self._stop:
|
|
24
|
+
raise StopIteration
|
|
25
|
+
else:
|
|
26
|
+
if self._current <= self._stop:
|
|
27
|
+
raise StopIteration
|
|
28
|
+
|
|
29
|
+
next_ = self._current
|
|
30
|
+
self._current += self._step
|
|
31
|
+
return next_
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DecimalRange:
|
|
35
|
+
def __init__(
|
|
36
|
+
self, start: decimal.Decimal, stop: decimal.Decimal, step: decimal.Decimal
|
|
37
|
+
) -> None:
|
|
38
|
+
self._start = start
|
|
39
|
+
self._stop = stop
|
|
40
|
+
self._step = step
|
|
41
|
+
|
|
42
|
+
if not (start.is_finite() and stop.is_finite()):
|
|
43
|
+
raise ValueError(
|
|
44
|
+
f"{self.__class__.__name__}() can only accept finite start and stop numbers"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
if not step.is_normal():
|
|
48
|
+
raise ValueError(
|
|
49
|
+
f"{self.__class__.__name__}() can only accept normal step numbers"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def start(self) -> decimal.Decimal:
|
|
54
|
+
return self._start
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def stop(self) -> decimal.Decimal:
|
|
58
|
+
return self._stop
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def step(self) -> decimal.Decimal:
|
|
62
|
+
return self._step
|
|
63
|
+
|
|
64
|
+
def __bool__(self) -> bool:
|
|
65
|
+
return bool(len(self))
|
|
66
|
+
|
|
67
|
+
def __contains__(self, key: object) -> bool:
|
|
68
|
+
if type(key) is not decimal.Decimal:
|
|
69
|
+
return any(key == v for v in self)
|
|
70
|
+
|
|
71
|
+
if self._step > 0:
|
|
72
|
+
if not (self._start <= key < self._stop):
|
|
73
|
+
return False
|
|
74
|
+
else:
|
|
75
|
+
if not (self._stop < key <= self._start):
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
# x_n = a + d(n-1)
|
|
79
|
+
# key = start + step * (n-1)
|
|
80
|
+
# key - start = step * (n-1)
|
|
81
|
+
# (key - start) / step = (n-1)
|
|
82
|
+
# (key - start) / step + 1 = n
|
|
83
|
+
# where n must be an integer
|
|
84
|
+
n_minus_one = (key - self._start) / self._step
|
|
85
|
+
return n_minus_one.as_integer_ratio()[1] == 1
|
|
86
|
+
|
|
87
|
+
def __eq__(self, value: object) -> bool:
|
|
88
|
+
if not isinstance(value, DecimalRange):
|
|
89
|
+
return NotImplemented
|
|
90
|
+
|
|
91
|
+
if len(self) != len(value):
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
if not len(self):
|
|
95
|
+
return True
|
|
96
|
+
|
|
97
|
+
if self.start != value.start:
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
if len(self) == 1:
|
|
101
|
+
return True
|
|
102
|
+
|
|
103
|
+
return self.step == value.step
|
|
104
|
+
|
|
105
|
+
def __hash__(self) -> int:
|
|
106
|
+
length = len(self)
|
|
107
|
+
to_hash: tuple[int, decimal.Decimal | None, None]
|
|
108
|
+
if length:
|
|
109
|
+
to_hash = (length, self.start, None)
|
|
110
|
+
else:
|
|
111
|
+
to_hash = (length, None, None)
|
|
112
|
+
return hash(to_hash)
|
|
113
|
+
|
|
114
|
+
def __iter__(self) -> Iterator[decimal.Decimal]:
|
|
115
|
+
return DecimalRangeIterator(self.start, self.stop, self.step)
|
|
116
|
+
|
|
117
|
+
def __len__(self) -> int:
|
|
118
|
+
# x_n = a + d(n-1)
|
|
119
|
+
# stop > start + step * (n-1)
|
|
120
|
+
# stop - start > step * (n-1)
|
|
121
|
+
# (stop - start) / step > (n-1)
|
|
122
|
+
# (stop - start) / step + 1 > n
|
|
123
|
+
|
|
124
|
+
if self._step > 0 and self._start < self._stop:
|
|
125
|
+
n_minus_one = (self._stop - self._start) / self._step
|
|
126
|
+
num, denom = n_minus_one.as_integer_ratio()
|
|
127
|
+
# stop is part of the range, but the range is non-inclusive
|
|
128
|
+
if denom == 1:
|
|
129
|
+
return num
|
|
130
|
+
|
|
131
|
+
return math.floor(n_minus_one + 1)
|
|
132
|
+
|
|
133
|
+
if self._step < 0 and self._start > self._stop:
|
|
134
|
+
n_minus_one = (self._stop - self._start) / self._step
|
|
135
|
+
num, denom = n_minus_one.as_integer_ratio()
|
|
136
|
+
# stop is part of the range, but the range is non-inclusive
|
|
137
|
+
if denom == 1:
|
|
138
|
+
return num
|
|
139
|
+
|
|
140
|
+
return math.floor(n_minus_one + 1)
|
|
141
|
+
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
def __repr__(self) -> str:
|
|
145
|
+
if self._step == 1:
|
|
146
|
+
return f"{self.__class__.__name__}({self._start}, {self._stop})"
|
|
147
|
+
|
|
148
|
+
return f"{self.__class__.__name__}({self._start}, {self._stop}, {self._step})"
|
|
149
|
+
|
|
150
|
+
def __reversed__(self) -> Iterator[decimal.Decimal]:
|
|
151
|
+
if not len(self):
|
|
152
|
+
return iter(())
|
|
153
|
+
|
|
154
|
+
new_stop = self._start - self._step
|
|
155
|
+
new_start = new_stop + self._step * len(self)
|
|
156
|
+
return DecimalRangeIterator(new_start, new_stop, -self._step)
|
|
157
|
+
|
|
158
|
+
def count(self, value: decimal.Decimal) -> int:
|
|
159
|
+
return 1 if value in self else 0
|
|
160
|
+
|
|
161
|
+
def index(self, value: decimal.Decimal) -> int:
|
|
162
|
+
# x_n = a + d(n-1)
|
|
163
|
+
# value = start + step * (n-1)
|
|
164
|
+
# value - start = step * (n-1)
|
|
165
|
+
# (value - start) / step = (n-1)
|
|
166
|
+
# (value - start) / step + 1 = n
|
|
167
|
+
# where n is an integer
|
|
168
|
+
# We don't +1 because we want 0-indexing.
|
|
169
|
+
if value not in self:
|
|
170
|
+
raise ValueError(f"{self.__class__.__name__}.index(x): x not in range")
|
|
171
|
+
|
|
172
|
+
return int((value - self._start) // self._step)
|