99import functools
1010import operator
1111import os
12+ import pathlib
1213import re
1314import sys
1415import typing
1516from typing import overload
1617from 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+
1827from . import constants , utils
1928from .constants import (
2029 PAD_STYLE_DEFAULT , PAD_MAP , REVERSE_PAD_MAP ,
2433from .exceptions import ParseException , FileSeqException
2534from .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