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/__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
|
pathseq/_ast/__init__.py
ADDED
|
@@ -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
|
+
)
|