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 ADDED
@@ -0,0 +1,44 @@
1
+ """A pathlib-first library for working with file sequences."""
2
+
3
+ from ._ast import (
4
+ FileNumT,
5
+ PaddedRange,
6
+ ParsedLooseSequence,
7
+ ParsedSequence,
8
+ Ranges,
9
+ RangesEndName,
10
+ RangesInName,
11
+ RangesStartName,
12
+ )
13
+ from ._base import BasePathSequence, BasePurePathSequence, PathT_co, PurePathT_co
14
+ from ._error import IncompleteDimensionError, NotASequenceError, ParseError
15
+ from ._file_num_seq import FileNumSequence
16
+ from ._loose_path_sequence import LoosePathSequence
17
+ from ._loose_pure_path_sequence import LoosePurePathSequence
18
+ from ._path_sequence import PathSequence
19
+ from ._pure_path_sequence import PurePathSequence
20
+
21
+ __version__ = "0.1.0"
22
+
23
+ __all__ = (
24
+ "BasePathSequence",
25
+ "BasePurePathSequence",
26
+ "FileNumSequence",
27
+ "FileNumT",
28
+ "IncompleteDimensionError",
29
+ "LoosePathSequence",
30
+ "LoosePurePathSequence",
31
+ "NotASequenceError",
32
+ "PaddedRange",
33
+ "ParsedLooseSequence",
34
+ "ParseError",
35
+ "ParsedSequence",
36
+ "PathSequence",
37
+ "PathT_co",
38
+ "PurePathSequence",
39
+ "PurePathT_co",
40
+ "Ranges",
41
+ "RangesEndName",
42
+ "RangesInName",
43
+ "RangesStartName",
44
+ )
@@ -0,0 +1,154 @@
1
+ from collections.abc import Iterator, Sequence
2
+ import decimal
3
+ from typing import overload, Protocol, TypeVar
4
+
5
+ from typing_extensions import Self # PY311
6
+
7
+ from ._ast import FileNumT
8
+ from ._decimal_range import DecimalRange
9
+
10
+
11
+ FileNumT_cov = TypeVar("FileNumT_cov", covariant=True)
12
+
13
+
14
+ class RangeProtocol(Protocol[FileNumT_cov]):
15
+ @property
16
+ def start(self) -> FileNumT_cov: ...
17
+ @property
18
+ def step(self) -> FileNumT_cov: ...
19
+
20
+ def __iter__(self) -> Iterator[FileNumT_cov]: ...
21
+
22
+ def __len__(self) -> int: ...
23
+
24
+
25
+ def remove_exponent(d: decimal.Decimal) -> decimal.Decimal:
26
+ """Remove the exponent and trailing zeros from the given decimal."""
27
+ return d.quantize(decimal.Decimal(1)) if d == d.to_integral() else d.normalize()
28
+
29
+
30
+ class ArithmeticSequence(Sequence[FileNumT]):
31
+ def __init__(
32
+ self, start: FileNumT, end: FileNumT | None = None, step: FileNumT | None = None
33
+ ) -> None:
34
+ end = end if end is not None else start
35
+ step = step if step is not None else start.__class__(1)
36
+
37
+ # Normalise to a positive step value
38
+ if step < 0:
39
+ start, end = (end, start)
40
+ step = -step
41
+
42
+ stop: FileNumT
43
+ if start < end:
44
+ # Normalise the end value to match the step
45
+ remainder = divmod(end - start, step)[1]
46
+ if remainder:
47
+ stop = end + (step - remainder)
48
+ end -= remainder
49
+ else:
50
+ stop = end + step
51
+ elif start == end:
52
+ # We treat the end as inclusive, ranges don't.
53
+ stop = end + step
54
+ # Normalise to a step of 1
55
+ step = start.__class__(1)
56
+ else:
57
+ stop = end
58
+
59
+ self._range: RangeProtocol[FileNumT]
60
+ self._end: FileNumT
61
+ if isinstance(start, int):
62
+ self._range = range(start, stop, step)
63
+ self._end = end
64
+ else:
65
+ self._range = DecimalRange(
66
+ remove_exponent(start), remove_exponent(stop), remove_exponent(step)
67
+ )
68
+ self._end = remove_exponent(end)
69
+
70
+ @property
71
+ def start(self) -> FileNumT:
72
+ return self._range.start
73
+
74
+ @property
75
+ def end(self) -> FileNumT:
76
+ return self._end
77
+
78
+ @property
79
+ def step(self) -> FileNumT:
80
+ return self._range.step
81
+
82
+ def __eq__(self, other: object) -> bool:
83
+ if not isinstance(other, ArithmeticSequence):
84
+ return NotImplemented
85
+
86
+ return (
87
+ isinstance(self.start, other.start.__class__)
88
+ and self.start == other.start
89
+ and self.end == other.end
90
+ and self.step == other.step
91
+ )
92
+
93
+ def __hash__(self) -> int:
94
+ return hash((type(self), self.start, self.end, self.step))
95
+
96
+ def __contains__(self, item: object) -> bool:
97
+ return any(item == x for x in self._range)
98
+
99
+ def __iter__(self) -> Iterator[FileNumT]:
100
+ return iter(self._range)
101
+
102
+ def __len__(self) -> int:
103
+ return len(self._range)
104
+
105
+ def __str__(self) -> str:
106
+ if len(self) == 1:
107
+ return str(self.start)
108
+
109
+ result = f"{self.start}-{self.end}"
110
+ if self.step != 1:
111
+ result += f"x{self.step}"
112
+ elif len(self) == 2:
113
+ return ",".join(str(x) for x in self)
114
+
115
+ return result
116
+
117
+ def __repr__(self) -> str:
118
+ if self.step == 1:
119
+ return f"{self.__class__.__name__}({self.start}, {self.end})"
120
+
121
+ return f"{self.__class__.__name__}({self.start}, {self.end}, {self.step})"
122
+
123
+ @overload
124
+ def __getitem__(self, index: int) -> FileNumT: ...
125
+
126
+ @overload
127
+ def __getitem__(self, index: slice) -> Self: ...
128
+
129
+ def __getitem__(self, index: int | slice) -> FileNumT | Self:
130
+ if isinstance(index, slice):
131
+ start_i, stop_i, step = index.indices(len(self))
132
+ if step > 0:
133
+ if start_i >= stop_i:
134
+ raise IndexError("slice creates an empty arithemtic sequence")
135
+ else:
136
+ if start_i <= stop_i:
137
+ raise IndexError("slice creates an empty arithemtic sequence")
138
+
139
+ start = self[index.start]
140
+ try:
141
+ stop = self[index.stop + 1]
142
+ except IndexError:
143
+ stop = self.end
144
+
145
+ return self.__class__(start, stop, self.step * index.step)
146
+
147
+ if index < 0:
148
+ index += len(self)
149
+ if index < 0:
150
+ raise IndexError("index out of range")
151
+ elif index >= len(self):
152
+ raise IndexError("index out of range")
153
+
154
+ return self._range.start + index * self._range.step
@@ -0,0 +1,22 @@
1
+ from ._base import FileNum, FileNumT, PaddedRange, Ranges
2
+ from ._formatter import Formatter
3
+ from ._loose_type import (
4
+ ParsedLooseSequence,
5
+ RangesStartName,
6
+ RangesInName,
7
+ RangesEndName,
8
+ )
9
+ from ._type import ParsedSequence
10
+
11
+ __all__ = [
12
+ "FileNum",
13
+ "FileNumT",
14
+ "Formatter",
15
+ "PaddedRange",
16
+ "ParsedLooseSequence",
17
+ "ParsedSequence",
18
+ "Ranges",
19
+ "RangesStartName",
20
+ "RangesInName",
21
+ "RangesEndName",
22
+ ]
pathseq/_ast/_base.py ADDED
@@ -0,0 +1,236 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable
4
+ import dataclasses
5
+ from dataclasses import dataclass
6
+ import decimal
7
+ from decimal import Decimal
8
+ import itertools
9
+ import typing
10
+ from typing import Any, Generic, TypeAlias, TypeGuard, TypeVar, Union
11
+
12
+ if typing.TYPE_CHECKING:
13
+ from .._file_num_seq import FileNumSequence
14
+
15
+ FileNumT = TypeVar("FileNumT", int, decimal.Decimal)
16
+ FileNum: TypeAlias = Union[int, decimal.Decimal]
17
+
18
+
19
+ def non_recursive_asdict(datacls: Any) -> dict[str, Any]:
20
+ """Like :func:`dataclasses.asdict` but does not recurse into attributes."""
21
+ return {
22
+ field.name: getattr(datacls, field.name)
23
+ for field in dataclasses.fields(datacls)
24
+ }
25
+
26
+
27
+ # TODO: Replace with formatter usage
28
+ def stringify_parsed_sequence(seq: Any) -> str:
29
+ result = ""
30
+ for field in dataclasses.fields(seq):
31
+ if field.name == "ranges":
32
+ ranges = (str(x) for x in seq.ranges.ranges)
33
+ # There's one more range than inter-range strings, so add an extra.
34
+ inter_ranges = itertools.chain(seq.ranges.inter_ranges, ("",))
35
+ result += "".join(itertools.chain.from_iterable(zip(ranges, inter_ranges)))
36
+ continue
37
+ elif field.name == "inter_ranges":
38
+ continue
39
+
40
+ value = getattr(seq, field.name)
41
+ if isinstance(value, (list, tuple)):
42
+ for item in value:
43
+ result += str(item)
44
+ else:
45
+ result += value
46
+
47
+ return result
48
+
49
+
50
+ def splice_strings_onto_ranges(
51
+ strings: Iterable[str], inter_ranges: Iterable[str]
52
+ ) -> str:
53
+ """Format the given range strings and inter-range strings together.
54
+
55
+ Raises:
56
+ TypeError: If the given number of strings does not match
57
+ the number of inter-range strings minus one.
58
+ """
59
+ result = ""
60
+
61
+ strs = iter(strings)
62
+ for inter_range in inter_ranges:
63
+ try:
64
+ result += next(strs)
65
+ except StopIteration:
66
+ break
67
+
68
+ result += inter_range
69
+ else:
70
+ try:
71
+ result += next(strs)
72
+ except StopIteration:
73
+ pass
74
+ else:
75
+ try:
76
+ next(strs)
77
+ except StopIteration:
78
+ return result
79
+
80
+ raise TypeError(
81
+ "The number of inter-range strings given does not match"
82
+ " the number of range strings given minus one."
83
+ )
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class PaddedRange(Generic[FileNumT]):
88
+ file_nums: FileNumSequence[FileNumT]
89
+ """The file numbers of each file in the sequence."""
90
+ pad_format: str
91
+ """The definition of how a file number is formatted in each file's name in the sequence."""
92
+
93
+ def __str__(self) -> str:
94
+ return str(self.file_nums) + self.pad_format
95
+
96
+ def __repr__(self) -> str:
97
+ return f"{self.__class__.__name__}({self})"
98
+
99
+ def format(self, number: int | Decimal) -> str:
100
+ """Format the given number using the range's padding rules."""
101
+ if self.pad_format == "<UVTILE>":
102
+ # udim = 1000+(v*10)+(u+1)
103
+ u = (number - 1) % 10
104
+ v = (number - 1000 - u - 1) // 10
105
+ return f"u{u + 1}_v{v + 1}"
106
+
107
+ pad_format = self.pad_format
108
+ if self.pad_format == "<UDIM>":
109
+ pad_format = "####"
110
+
111
+ if "." in pad_format:
112
+ head, tail = pad_format.split(".", 1)
113
+ return pad(number, len(head), len(tail))
114
+
115
+ return pad(number, len(pad_format))
116
+
117
+ @staticmethod
118
+ def has_subsamples(
119
+ range_: PaddedRange[int] | PaddedRange[decimal.Decimal],
120
+ ) -> TypeGuard[PaddedRange[decimal.Decimal]]:
121
+ """Check whether this file number sequence contains any decimal numbers."""
122
+ return range_.file_nums.has_subsamples(range_.file_nums)
123
+
124
+
125
+ @dataclass(frozen=True)
126
+ class Ranges:
127
+ ranges: tuple[PaddedRange[int] | PaddedRange[Decimal], ...]
128
+ """Each :ref:`range specifier <format-simple-ranges>` in the sequence."""
129
+ inter_ranges: tuple[str, ...]
130
+ """The inter-range separators between each range in the sequence.
131
+
132
+ The number of inter-range separators is guaranteed to be ``1 - len(self.ranges)``.
133
+ """
134
+
135
+ def __post_init__(self) -> None:
136
+ if len(self.inter_ranges) != len(self.ranges) - 1:
137
+ raise ValueError(
138
+ "The number of inter-range strings given does not match"
139
+ " the number of range strings minus one."
140
+ )
141
+
142
+ def __str__(self) -> str:
143
+ return splice_strings_onto_ranges(
144
+ (str(range_) for range_ in self.ranges),
145
+ self.inter_ranges,
146
+ )
147
+
148
+ def __repr__(self) -> str:
149
+ return f"{self.__class__.__name__}({self})"
150
+
151
+
152
+ # The MIT License (MIT)
153
+
154
+ # Original work Copyright (c) 2015 Matthew Chambers
155
+ # Modified work Copyright 2015 Justin Israel
156
+
157
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
158
+ # of this software and associated documentation files (the "Software"), to deal
159
+ # in the Software without restriction, including without limitation the rights
160
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
161
+ # copies of the Software, and to permit persons to whom the Software is
162
+ # furnished to do so, subject to the following conditions:
163
+
164
+ # The above copyright notice and this permission notice shall be included in
165
+ # all copies or substantial portions of the Software.
166
+
167
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
168
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
169
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
170
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
171
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
172
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
173
+ # THE SOFTWARE.
174
+
175
+ # TODO: Validate that this is doing what we want
176
+
177
+
178
+ def quantize(
179
+ number: decimal.Decimal,
180
+ decimal_places: int,
181
+ rounding: str = decimal.ROUND_HALF_EVEN,
182
+ ) -> decimal.Decimal:
183
+ """Round a decimal value to given number of decimal places
184
+
185
+ Args:
186
+ number: Decimal number to round
187
+ decimal_places: Number of decimal places in return value
188
+ rounding: decimal.Decimal rounding mode. See rounding argument of
189
+ https://docs.python.org/2/library/decimal.html#decimal.Context
190
+
191
+ Returns:
192
+ decimal.Decimal:
193
+ """
194
+ quantize_exponent = decimal.Decimal(1).scaleb(-decimal_places)
195
+ nq = number.quantize(quantize_exponent, rounding=rounding)
196
+ if nq.is_zero():
197
+ return nq.copy_abs()
198
+ return nq
199
+
200
+
201
+ def pad(
202
+ number: int | Decimal, width: int = 0, decimal_places: int | None = None
203
+ ) -> str:
204
+ """Return the zero-padded string of a given number.
205
+
206
+ Args:
207
+ number: the number to pad
208
+ width: width for zero padding the integral component
209
+ decimal_places: number of decimal places to use in frame range
210
+ """
211
+
212
+ # Make the common case fast. Truncate to integer value as USD does.
213
+ # https://graphics.pixar.com/usd/docs/api/_usd__page__value_clips.html
214
+ # See _DeriveClipTimeString for formatting of templateAssetPath
215
+ # https://github.com/PixarAnimationStudios/USD/blob/release/pxr/usd/usd/clipSetDefinition.cpp
216
+ if decimal_places == 0:
217
+ try:
218
+ number = round(number) or 0
219
+ except TypeError:
220
+ pass
221
+ return str(number).partition(".")[0].zfill(width)
222
+
223
+ # USD ultimately uses vsnprintf to format floats for templateAssetPath:
224
+ # _DeriveClipTimeString -> TfStringPrintf -> ArchVStringPrintf -> ArchVsnprintf -> vsnprintf
225
+ # Since glibc 2.17 the printf family of functions rounds floats using the
226
+ # current IEEE rounding mode, by default bankers' rounding (FE_TONEAREST).
227
+ # See https://sourceware.org/bugzilla/show_bug.cgi?id=5044 and man(3) fegetround
228
+ # Also https://www.exploringbinary.com/inconsistent-rounding-of-printed-floating-point-numbers/
229
+ if decimal_places is not None:
230
+ if not isinstance(number, decimal.Decimal):
231
+ number = decimal.Decimal(number)
232
+ number = quantize(number, decimal_places, decimal.ROUND_HALF_EVEN)
233
+
234
+ parts = str(number).split(".", 1)
235
+ parts[0] = parts[0].zfill(width)
236
+ return ".".join(parts)
@@ -0,0 +1,38 @@
1
+ import dataclasses
2
+ from decimal import Decimal
3
+
4
+ from ._base import PaddedRange, Ranges, splice_strings_onto_ranges
5
+ from ._loose_type import ParsedLooseSequence
6
+ from ._type import ParsedSequence
7
+
8
+
9
+ class Formatter:
10
+ def stem(self, stem: str) -> str:
11
+ return stem
12
+
13
+ def prefix(self, prefix: str) -> str:
14
+ return prefix
15
+
16
+ def range(self, range_: PaddedRange[int] | PaddedRange[Decimal]) -> str:
17
+ return str(range_)
18
+
19
+ def inter_range(self, inter_range: str) -> str:
20
+ return inter_range
21
+
22
+ def ranges(self, ranges: Ranges) -> str:
23
+ return splice_strings_onto_ranges(
24
+ (self.range(range_) for range_ in ranges.ranges),
25
+ (self.inter_range(inter_range) for inter_range in ranges.inter_ranges),
26
+ )
27
+
28
+ def postfix(self, postfix: str) -> str:
29
+ return postfix
30
+
31
+ def suffixes(self, suffixes: tuple[str, ...]) -> str:
32
+ return "".join(suffixes)
33
+
34
+ def format(self, seq: ParsedSequence | ParsedLooseSequence) -> str:
35
+ return "".join(
36
+ getattr(self, field.name)(getattr(seq, field.name))
37
+ for field in dataclasses.fields(seq)
38
+ )