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/_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)