Skip to content

Commit 4ae10cd

Browse files
authored
Merge pull request #140 from chadrik/pathlib
Add support for pathlib.Path via a new FilePathSequence class
2 parents fe94cfb + 5be4105 commit 4ae10cd

File tree

4 files changed

+1476
-1312
lines changed

4 files changed

+1476
-1312
lines changed

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,13 +125,37 @@ fileseq.FileSequence("/foo/bar.1-10x0.25#.#.exr", allow_subframes=True)
125125
'/foo/bar.0010.exr']
126126
```
127127

128+
### Get List of File Paths as `pathlib.Path` instances
129+
`fileseq.FilePathSequence` supports the same semantics as `fileseq.FileSequence` but represents result paths as [`pathlib.Path`](https://docs.python.org/3/library/pathlib.html) instances instead of strings.
130+
131+
```python
132+
>>> seq = fileseq.FilePathSequence("/foo/bar.1-10#.exr")
133+
>>> [seq[idx] for idx, fr in enumerate(seq.frameSet())]
134+
[PosixPath('/foo/bar.0001.exr'),
135+
PosixPath('/foo/bar.0002.exr'),
136+
PosixPath('/foo/bar.0003.exr'),
137+
PosixPath('/foo/bar.0004.exr'),
138+
PosixPath('/foo/bar.0005.exr'),
139+
PosixPath('/foo/bar.0006.exr'),
140+
PosixPath('/foo/bar.0007.exr'),
141+
PosixPath('/foo/bar.0008.exr'),
142+
PosixPath('/foo/bar.0009.exr'),
143+
PosixPath('/foo/bar.0010.exr')]
144+
```
145+
128146
## Finding Sequences on Disk
129147

130148
### Check a Directory for All Existing Sequences
131149
```python
132150
seqs = fileseq.findSequencesOnDisk("/show/shot/renders/bty_foo/v1")
133151
```
134152

153+
Or, to get results as `pathlib.Path`, use the `FilePathSequence` classmethod:
154+
155+
```python
156+
seqs = fileseq.FilePathSequence.findSequencesOnDisk("/show/shot/renders/bty_foo/v1")
157+
```
158+
135159
### Check a Directory for One Existing Sequence.
136160
* Use a '@' or '#' where you might expect to use '*' for a wildcard character.
137161
* For this method, it doesn't matter how many instances of the padding character you use, it will still find your sequence.

src/fileseq/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@
174174
from .constants import PAD_STYLE_DEFAULT, PAD_STYLE_HASH1, PAD_STYLE_HASH4
175175
from .exceptions import ParseException, MaxSizeException, FileSeqException
176176
from .frameset import FrameSet
177-
from .filesequence import FileSequence
177+
from .filesequence import BaseFileSequence, FileSequence, FilePathSequence
178178

179179
padFrameRange = FrameSet.padFrameRange
180180
framesToFrameRange = FrameSet.framesToFrameRange

src/fileseq/filesequence.py

Lines changed: 101 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,21 @@
99
import functools
1010
import operator
1111
import os
12+
import pathlib
1213
import re
1314
import sys
1415
import typing
1516
from typing import overload
1617
from glob import iglob
1718

19+
if typing.TYPE_CHECKING:
20+
# in order to satisfy mypy without adding typing_extensions as a dependency
21+
# we don't import Self at runtime
22+
from typing_extensions import Self
23+
else:
24+
# at runtime we create a placeholder object.
25+
Self = object()
26+
1827
from . import constants, utils
1928
from .constants import (
2029
PAD_STYLE_DEFAULT, PAD_MAP, REVERSE_PAD_MAP,
@@ -24,8 +33,11 @@
2433
from .exceptions import ParseException, FileSeqException
2534
from .frameset import FrameSet
2635

36+
# Type variables for generic base class
37+
T = typing.TypeVar('T', covariant=True)
38+
2739

28-
class FileSequence:
40+
class BaseFileSequence(typing.Generic[T]):
2941
""":class:`FileSequence` represents an ordered sequence of files.
3042
3143
Args:
@@ -148,7 +160,20 @@ def __init__(self,
148160
for frame in self._frameSet
149161
])
150162

151-
def copy(self) -> FileSequence:
163+
def _create_path(self, path_str: str) -> T:
164+
"""
165+
Abstract method to create the appropriate path type from a string.
166+
Must be implemented by subclasses.
167+
168+
Args:
169+
path_str (str): The path as a string
170+
171+
Returns:
172+
T: The path in the appropriate type for this sequence
173+
"""
174+
raise NotImplementedError("Subclasses must implement _create_path")
175+
176+
def copy(self) -> Self:
152177
"""
153178
Create a deep copy of this sequence
154179
@@ -210,7 +235,7 @@ def _format(self, template: str) -> str:
210235
inverted=inverted,
211236
dirname=self.dirname())
212237

213-
def split(self) -> list[FileSequence]:
238+
def split(self) -> list[BaseFileSequence[T]]:
214239
"""
215240
Split the :class:`FileSequence` into contiguous pieces and return them
216241
as a list of :class:`FileSequence` instances.
@@ -561,7 +586,7 @@ def decimalPlaces(self) -> int:
561586
"""
562587
return self._decimal_places
563588

564-
def frame(self, frame: int|float|decimal.Decimal|str) -> str:
589+
def frame(self, frame: int|float|decimal.Decimal|str) -> T:
565590
"""
566591
Return a path for the given frame in the sequence. Numeric values or
567592
numeric strings are treated as a frame number and padding is applied,
@@ -599,9 +624,9 @@ def frame(self, frame: int|float|decimal.Decimal|str) -> str:
599624
if zframe is None:
600625
zframe = utils.pad(frame, self._zfill, self._decimal_places)
601626

602-
return str("".join((self._dir, self._base, str(zframe), self._ext)))
627+
return self._create_path("".join((self._dir, self._base, str(zframe), self._ext)))
603628

604-
def index(self, idx: int) -> str:
629+
def index(self, idx: int) -> T:
605630
"""
606631
Return the path to the file at the given index.
607632
@@ -614,14 +639,14 @@ def index(self, idx: int) -> str:
614639
return self.__getitem__(idx)
615640

616641
@overload
617-
def batches(self, batch_size: int, paths: typing.Literal[True]) -> typing.Iterator[utils._islice[str]]:
642+
def batches(self, batch_size: int, paths: typing.Literal[True]) -> typing.Iterator[utils._islice[T]]:
618643
...
619644

620645
@overload
621-
def batches(self, batch_size: int, paths: typing.Literal[False] = ...) -> typing.Iterator[FileSequence]:
646+
def batches(self, batch_size: int, paths: typing.Literal[False] = ...) -> typing.Iterator[Self]:
622647
...
623648

624-
def batches(self, batch_size: int, paths: bool = False) -> typing.Iterator[utils._islice[str]] | typing.Iterator[FileSequence]:
649+
def batches(self, batch_size: int, paths: bool = False) -> typing.Iterator[utils._islice[T]] | typing.Iterator[Self]:
625650
"""
626651
Returns a generator that yields groups of file paths, up to ``batch_size``.
627652
Convenience method for ``fileseq.utils.batchIterable(self, batch_size)``
@@ -677,7 +702,7 @@ def to_dict(self) -> dict[str, typing.Any]:
677702
return state
678703

679704
@classmethod
680-
def from_dict(cls, state: dict[str, typing.Any]) -> FileSequence:
705+
def from_dict(cls, state: dict[str, typing.Any]) -> Self:
681706
"""
682707
Constructor to create a new sequence object from a state
683708
that was previously returned by :meth:`FileSequence.to_dict`
@@ -686,7 +711,7 @@ def from_dict(cls, state: dict[str, typing.Any]) -> FileSequence:
686711
state (dict): state returned from :meth:`FileSequence.to_dict`
687712
688713
Returns:
689-
:obj:`FileSequence`
714+
:obj:`Self`
690715
"""
691716
state = state.copy()
692717
frameSet = FrameSet.__new__(FrameSet)
@@ -700,7 +725,7 @@ def from_dict(cls, state: dict[str, typing.Any]) -> FileSequence:
700725
fs.__setstate__(state)
701726
return fs
702727

703-
def __iter__(self) -> typing.Iterator[str]:
728+
def __iter__(self) -> typing.Iterator[T]:
704729
"""
705730
Allow iteration over the path or paths this :class:`FileSequence`
706731
represents.
@@ -711,21 +736,21 @@ def __iter__(self) -> typing.Iterator[str]:
711736
# If there is no frame range, or there is no padding
712737
# characters, then we only want to represent a single path
713738
if not self._frameSet or not self._zfill:
714-
yield utils.asString(self)
739+
yield self._create_path(utils.asString(self))
715740
return
716741

717742
for f in self._frameSet:
718743
yield self.frame(f)
719744

720745
@typing.overload
721-
def __getitem__(self, idx: slice) -> FileSequence:
746+
def __getitem__(self, idx: slice) -> Self:
722747
pass
723748

724749
@typing.overload
725-
def __getitem__(self, idx: int) -> str:
750+
def __getitem__(self, idx: int) -> T:
726751
pass
727752

728-
def __getitem__(self, idx: typing.Any) -> str | FileSequence:
753+
def __getitem__(self, idx: typing.Any) -> T | Self:
729754
"""
730755
Allows indexing and slicing into the underlying :class:`.FrameSet`
731756
@@ -745,7 +770,7 @@ def __getitem__(self, idx: typing.Any) -> str | FileSequence:
745770
:class:`IndexError`: If slice is outside the range of the sequence
746771
"""
747772
if not self._frameSet:
748-
return str(self)
773+
return self._create_path(str(self))
749774

750775
frames = self._frameSet[idx]
751776

@@ -796,7 +821,7 @@ def __repr__(self) -> str:
796821
return super(self.__class__, self).__repr__()
797822

798823
def __eq__(self, other: typing.Any) -> bool:
799-
if not isinstance(other, FileSequence):
824+
if not isinstance(other, BaseFileSequence):
800825
return str(self) == str(other)
801826

802827
a = self.__components()
@@ -829,11 +854,11 @@ def __components(self) -> _Components:
829854

830855
@classmethod
831856
def yield_sequences_in_list(
832-
cls,
833-
paths: typing.Iterable[str],
834-
using: 'FileSequence | None' = None,
857+
cls: type[Self],
858+
paths: typing.Iterable[str | pathlib.Path],
859+
using: BaseFileSequence[T] | None = None,
835860
pad_style: constants._PadStyle = PAD_STYLE_DEFAULT,
836-
allow_subframes: bool = False) -> typing.Iterator[FileSequence]:
861+
allow_subframes: bool = False) -> typing.Iterator[Self]:
837862
"""
838863
Yield the discrete sequences within paths. This does not try to
839864
determine if the files actually exist on disk, it assumes you already
@@ -874,7 +899,7 @@ def yield_sequences_in_list(
874899
else:
875900
_check = cls.DISK_RE.match
876901

877-
if isinstance(using, FileSequence):
902+
if isinstance(using, BaseFileSequence):
878903
dirname, basename, ext = using.dirname(), using.basename(), using.extension()
879904
head: int = len(dirname + basename)
880905
tail: int = -len(ext)
@@ -910,19 +935,20 @@ def yield_sequences_in_list(
910935
if frame:
911936
seqs[key].add(frame)
912937

913-
def start_new_seq() -> FileSequence:
914-
seq: FileSequence = cls.__new__(cls)
938+
def start_new_seq(cls: type[Self]) -> Self:
939+
seq: Self = cls.__new__(cls)
915940
seq._dir = dirname or ''
916941
seq._base = basename or ''
917942
seq._ext = ext or ''
918943
return seq
919944

920-
def finish_new_seq(seq: FileSequence) -> None:
945+
def finish_new_seq(seq: Self) -> None:
921946
if seq._subframe_pad:
922947
seq._pad = '.'.join([seq._frame_pad, seq._subframe_pad])
923948
else:
924949
seq._pad = seq._frame_pad
925950

951+
# Use a type: ignore to suppress the mypy warning about calling __init__ on instance
926952
seq.__init__(utils.asString(seq), pad_style=pad_style, # type: ignore[misc]
927953
allow_subframes=allow_subframes)
928954

@@ -940,8 +966,8 @@ def get_frame_minwidth(frame_str: str) -> int:
940966
return 1
941967
return size
942968

943-
def frames_to_seq(frames: typing.Iterable[str], pad_length: int, decimal_places: int) -> FileSequence:
944-
seq = start_new_seq()
969+
def frames_to_seq(cls: type[Self], frames: typing.Iterable[str], pad_length: int, decimal_places: int) -> Self:
970+
seq = start_new_seq(cls)
945971
seq._frameSet = FrameSet(sorted(decimal.Decimal(f) for f in frames))
946972
seq._frame_pad = cls.getPaddingChars(pad_length, pad_style=pad_style)
947973
if decimal_places:
@@ -955,7 +981,7 @@ def frames_to_seq(frames: typing.Iterable[str], pad_length: int, decimal_places:
955981
# Short-circuit logic if we do not have multiple frames, since we
956982
# only need to build and return a single simple sequence
957983
if not frames:
958-
seq = start_new_seq()
984+
seq = start_new_seq(cls)
959985
seq._frameSet = None
960986
seq._frame_pad = ''
961987
seq._subframe_pad = ''
@@ -980,7 +1006,7 @@ def frames_to_seq(frames: typing.Iterable[str], pad_length: int, decimal_places:
9801006
if width != current_width and get_frame_minwidth(frame) > current_width:
9811007
# We have a new padding length.
9821008
# Commit the current sequence, and then start a new one.
983-
yield frames_to_seq(current_frames, current_width, decimal_places)
1009+
yield frames_to_seq(cls, current_frames, current_width, decimal_places)
9841010

9851011
# Start tracking the next group of frames using the new length
9861012
current_frames = [frame]
@@ -991,20 +1017,20 @@ def frames_to_seq(frames: typing.Iterable[str], pad_length: int, decimal_places:
9911017

9921018
# Commit the remaining frames as a sequence
9931019
if current_frames:
994-
yield frames_to_seq(current_frames, current_width, decimal_places)
1020+
yield frames_to_seq(cls, current_frames, current_width, decimal_places)
9951021

9961022
@classmethod
9971023
def findSequencesInList(cls,
998-
paths: typing.Iterable[str],
1024+
paths: typing.Iterable[str | pathlib.Path],
9991025
pad_style: constants._PadStyle = PAD_STYLE_DEFAULT,
1000-
allow_subframes: bool = False) -> list[FileSequence]:
1026+
allow_subframes: bool = False) -> list[Self]:
10011027
"""
10021028
Returns the list of discrete sequences within paths. This does not try
10031029
to determine if the files actually exist on disk, it assumes you
10041030
already know that.
10051031
10061032
Args:
1007-
paths (list[str]): a list of paths
1033+
paths (list[str | pathlib.Path]): a list of paths
10081034
pad_style (`PAD_STYLE_DEFAULT` or `PAD_STYLE_HASH1` or `PAD_STYLE_HASH4`): padding style
10091035
allow_subframes (bool): if True, handle subframe filenames
10101036
@@ -1022,7 +1048,7 @@ def findSequencesOnDisk(
10221048
include_hidden: bool = False,
10231049
strictPadding: bool = False,
10241050
pad_style: constants._PadStyle = PAD_STYLE_DEFAULT,
1025-
allow_subframes: bool = False) -> list[FileSequence]:
1051+
allow_subframes: bool = False) -> list[Self]:
10261052
"""
10271053
Yield the sequences found in the given directory.
10281054
@@ -1160,7 +1186,7 @@ def findSequenceOnDisk(
11601186
pad_style: constants._PadStyle = PAD_STYLE_DEFAULT,
11611187
allow_subframes: bool = False,
11621188
force_case_sensitive: bool = True,
1163-
preserve_padding: bool = False) -> FileSequence:
1189+
preserve_padding: bool = False) -> Self:
11641190
"""
11651191
Search for a specific sequence on disk.
11661192
@@ -1209,7 +1235,7 @@ def findSequenceOnDisk(
12091235
sequence, if the padding length matches. Default: conform padding to "#@" style.
12101236
12111237
Returns:
1212-
FileSequence: A single matching file sequence existing on disk
1238+
Self: A single matching file sequence existing on disk
12131239
12141240
Raises:
12151241
:class:`.FileSeqException`: if no sequence is found on disk
@@ -1540,3 +1566,41 @@ def conformPadding(cls, chars: str, pad_style: constants._PadStyle = PAD_STYLE_D
15401566
num = cls.getPaddingNum(pad, pad_style=pad_style)
15411567
pad = cls.getPaddingChars(num, pad_style=pad_style)
15421568
return pad
1569+
1570+
1571+
class FileSequence(BaseFileSequence[str]):
1572+
""":class:`FileSequence` represents an ordered sequence of files as strings.
1573+
1574+
Args:
1575+
sequence (str): (ie: dir/path.1-100#.ext)
1576+
1577+
Returns:
1578+
:class:`FileSequence`:
1579+
1580+
Raises:
1581+
:class:`fileseq.exceptions.MaxSizeException`: If frame size exceeds
1582+
``fileseq.constants.MAX_FRAME_SIZE``
1583+
"""
1584+
1585+
def _create_path(self, path_str: str) -> str:
1586+
"""Create a string path from a string."""
1587+
return path_str
1588+
1589+
1590+
class FilePathSequence(BaseFileSequence[pathlib.Path]):
1591+
""":class:`FilePathSequence` represents an ordered sequence of files as pathlib.Path objects.
1592+
1593+
Args:
1594+
sequence (str): (ie: dir/path.1-100#.ext)
1595+
1596+
Returns:
1597+
:class:`FilePathSequence`:
1598+
1599+
Raises:
1600+
:class:`fileseq.exceptions.MaxSizeException`: If frame size exceeds
1601+
``fileseq.constants.MAX_FRAME_SIZE``
1602+
"""
1603+
1604+
def _create_path(self, path_str: str) -> pathlib.Path:
1605+
"""Create a pathlib.Path from a string."""
1606+
return pathlib.Path(path_str)

0 commit comments

Comments
 (0)