fixfield 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.
- fixfield/__init__.py +17 -0
- fixfield/field.py +73 -0
- fixfield/py.typed +0 -0
- fixfield/record.py +175 -0
- fixfield/rounding.py +48 -0
- fixfield/types.py +160 -0
- fixfield-0.1.0.dist-info/METADATA +248 -0
- fixfield-0.1.0.dist-info/RECORD +10 -0
- fixfield-0.1.0.dist-info/WHEEL +4 -0
- fixfield-0.1.0.dist-info/licenses/LICENSE +21 -0
fixfield/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from fixfield.rounding import RoundingStrategy
|
|
2
|
+
from fixfield.types import FixedDecimal, FieldOverflowError
|
|
3
|
+
from fixfield.field import Field, FieldValue
|
|
4
|
+
from fixfield.record import Record, RecordField
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.0"
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"__version__",
|
|
10
|
+
"RoundingStrategy",
|
|
11
|
+
"FixedDecimal",
|
|
12
|
+
"FieldOverflowError",
|
|
13
|
+
"Field",
|
|
14
|
+
"FieldValue",
|
|
15
|
+
"Record",
|
|
16
|
+
"RecordField",
|
|
17
|
+
]
|
fixfield/field.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import overload
|
|
4
|
+
from fixfield.rounding import RoundingStrategy
|
|
5
|
+
from fixfield.types import FixedDecimal, _NUMBER
|
|
6
|
+
|
|
7
|
+
type FieldValue = _NUMBER | FixedDecimal
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Field:
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
places: int = 2,
|
|
14
|
+
rounding: RoundingStrategy = RoundingStrategy.ROUND_HALF_UP,
|
|
15
|
+
default: _NUMBER | None = None,
|
|
16
|
+
digits: int | None = None,
|
|
17
|
+
) -> None:
|
|
18
|
+
self.places = places
|
|
19
|
+
self.rounding = rounding
|
|
20
|
+
self.digits = digits
|
|
21
|
+
self.default = (
|
|
22
|
+
FixedDecimal(default, places, rounding, digits)
|
|
23
|
+
if default is not None
|
|
24
|
+
else None
|
|
25
|
+
)
|
|
26
|
+
self._attr: str = "" # set by __set_name__
|
|
27
|
+
|
|
28
|
+
def __set_name__(self, owner: type, name: str) -> None:
|
|
29
|
+
self._attr = f"_field_{name}"
|
|
30
|
+
|
|
31
|
+
@overload
|
|
32
|
+
def __get__(self, obj: None, objtype: type) -> Field: ...
|
|
33
|
+
|
|
34
|
+
@overload
|
|
35
|
+
def __get__(self, obj: object, objtype: type) -> FixedDecimal: ...
|
|
36
|
+
|
|
37
|
+
def __get__(self, obj: object | None, objtype: type) -> Field | FixedDecimal:
|
|
38
|
+
if obj is None:
|
|
39
|
+
return self
|
|
40
|
+
value = obj.__dict__.get(self._attr)
|
|
41
|
+
if value is None:
|
|
42
|
+
if self.default is not None:
|
|
43
|
+
return self.default
|
|
44
|
+
return FixedDecimal(0, self.places, self.rounding, self.digits)
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
def __set__(self, obj: object, value: FieldValue) -> None:
|
|
48
|
+
raw = value.value if isinstance(value, FixedDecimal) else value
|
|
49
|
+
obj.__dict__[self._attr] = FixedDecimal(
|
|
50
|
+
raw, self.places, self.rounding, self.digits
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def width(self) -> int:
|
|
55
|
+
"""
|
|
56
|
+
Fixed-width character length for this field.
|
|
57
|
+
Requires ``digits`` to be set.
|
|
58
|
+
|
|
59
|
+
Format: [sign(1)] + [integer digits] + [. + decimal places if places > 0]
|
|
60
|
+
Example: digits=5, places=2 → "-99999.99" → width 9
|
|
61
|
+
"""
|
|
62
|
+
if self.digits is None:
|
|
63
|
+
raise ValueError(
|
|
64
|
+
"Field must have 'digits' set to use fixed-width serialization"
|
|
65
|
+
)
|
|
66
|
+
decimal_part = 1 + self.places if self.places > 0 else 0
|
|
67
|
+
return 1 + self.digits + decimal_part # 1 for sign
|
|
68
|
+
|
|
69
|
+
def __repr__(self) -> str:
|
|
70
|
+
return (
|
|
71
|
+
f"Field(places={self.places}, rounding={self.rounding}, "
|
|
72
|
+
f"digits={self.digits}, default={self.default})"
|
|
73
|
+
)
|
fixfield/py.typed
ADDED
|
File without changes
|
fixfield/record.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Generic, Self, TypeVar, overload
|
|
4
|
+
from fixfield.field import Field, FieldValue
|
|
5
|
+
from fixfield.types import FixedDecimal
|
|
6
|
+
|
|
7
|
+
_R = TypeVar("_R", bound="Record")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RecordField(Generic[_R]):
|
|
11
|
+
"""
|
|
12
|
+
Descriptor for embedding a nested Record as a field within another Record.
|
|
13
|
+
|
|
14
|
+
Example::
|
|
15
|
+
|
|
16
|
+
class Address(Record):
|
|
17
|
+
zip_code = Field(places=0, digits=5)
|
|
18
|
+
|
|
19
|
+
class Customer(Record):
|
|
20
|
+
customer_id = Field(places=0, digits=6)
|
|
21
|
+
address = RecordField(Address)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, record_type: type[_R]) -> None:
|
|
25
|
+
self.record_type = record_type
|
|
26
|
+
self._attr: str = ""
|
|
27
|
+
|
|
28
|
+
def __set_name__(self, owner: type, name: str) -> None:
|
|
29
|
+
self._attr = f"_recordfield_{name}"
|
|
30
|
+
|
|
31
|
+
@overload
|
|
32
|
+
def __get__(self, obj: None, objtype: type) -> RecordField[_R]: ...
|
|
33
|
+
@overload
|
|
34
|
+
def __get__(self, obj: object, objtype: type) -> _R: ...
|
|
35
|
+
|
|
36
|
+
def __get__(self, obj: object | None, objtype: type) -> RecordField[_R] | _R:
|
|
37
|
+
if obj is None:
|
|
38
|
+
return self
|
|
39
|
+
value = obj.__dict__.get(self._attr)
|
|
40
|
+
if value is None:
|
|
41
|
+
return self.record_type()
|
|
42
|
+
return value # type: ignore[return-value]
|
|
43
|
+
|
|
44
|
+
def __set__(self, obj: object, value: _R) -> None:
|
|
45
|
+
if not isinstance(value, self.record_type):
|
|
46
|
+
raise TypeError(
|
|
47
|
+
f"Expected {self.record_type.__name__}, got {type(value).__name__}"
|
|
48
|
+
)
|
|
49
|
+
obj.__dict__[self._attr] = value
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def width(self) -> int:
|
|
53
|
+
"""Total fixed-width character length of the nested record."""
|
|
54
|
+
return sum(attr.width for attr in self.record_type._all_attrs.values())
|
|
55
|
+
|
|
56
|
+
def __repr__(self) -> str:
|
|
57
|
+
return f"RecordField({self.record_type.__name__})"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Record:
|
|
61
|
+
"""
|
|
62
|
+
Base class for structured groups of Fields.
|
|
63
|
+
|
|
64
|
+
Subclass and declare Fields (and optionally RecordFields) as class
|
|
65
|
+
attributes. Record generates an __init__ that accepts values for each
|
|
66
|
+
declared attribute by keyword, coercing each through its declared
|
|
67
|
+
precision automatically.
|
|
68
|
+
|
|
69
|
+
Arithmetic convention: the LEFT operand's precision governs the result.
|
|
70
|
+
|
|
71
|
+
Example::
|
|
72
|
+
|
|
73
|
+
class Invoice(Record):
|
|
74
|
+
price = Field(places=2)
|
|
75
|
+
tax_rate = Field(places=4)
|
|
76
|
+
total = Field(places=2)
|
|
77
|
+
|
|
78
|
+
inv = Invoice(price="19.99", tax_rate="0.0825", total="0")
|
|
79
|
+
inv.total = inv.price * inv.tax_rate + inv.price
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init_subclass__(cls, **kwargs: object) -> None:
|
|
83
|
+
super().__init_subclass__(**kwargs)
|
|
84
|
+
# Collect all declared fields in declaration order
|
|
85
|
+
all_attrs: dict[str, Field | RecordField] = {
|
|
86
|
+
name: obj
|
|
87
|
+
for name, obj in cls.__dict__.items()
|
|
88
|
+
if isinstance(obj, (Field, RecordField))
|
|
89
|
+
}
|
|
90
|
+
cls._all_attrs: dict[str, Field | RecordField] = all_attrs
|
|
91
|
+
cls._fields: dict[str, Field] = {
|
|
92
|
+
n: o for n, o in all_attrs.items() if isinstance(o, Field)
|
|
93
|
+
}
|
|
94
|
+
cls._record_fields: dict[str, RecordField] = {
|
|
95
|
+
n: o for n, o in all_attrs.items() if isinstance(o, RecordField)
|
|
96
|
+
}
|
|
97
|
+
cls.__init__ = _make_init(all_attrs) # type: ignore[method-assign]
|
|
98
|
+
|
|
99
|
+
def __repr__(self) -> str:
|
|
100
|
+
parts = ", ".join(
|
|
101
|
+
f"{name}={getattr(self, name)!s}"
|
|
102
|
+
for name in self._all_attrs
|
|
103
|
+
)
|
|
104
|
+
return f"{type(self).__name__}({parts})"
|
|
105
|
+
|
|
106
|
+
def __eq__(self, other: object) -> bool:
|
|
107
|
+
if type(self) is not type(other):
|
|
108
|
+
return NotImplemented
|
|
109
|
+
return all(
|
|
110
|
+
getattr(self, name) == getattr(other, name)
|
|
111
|
+
for name in self._all_attrs
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def to_dict(self) -> dict[str, FixedDecimal | Record]:
|
|
115
|
+
return {name: getattr(self, name) for name in self._all_attrs}
|
|
116
|
+
|
|
117
|
+
def to_string(self) -> str:
|
|
118
|
+
"""
|
|
119
|
+
Serialise the record to a fixed-width string.
|
|
120
|
+
Every Field must have ``digits`` set. RecordFields recurse into
|
|
121
|
+
their nested record's ``to_string``.
|
|
122
|
+
"""
|
|
123
|
+
parts: list[str] = []
|
|
124
|
+
for name, attr in self._all_attrs.items():
|
|
125
|
+
value = getattr(self, name)
|
|
126
|
+
if isinstance(attr, RecordField):
|
|
127
|
+
parts.append(value.to_string())
|
|
128
|
+
else:
|
|
129
|
+
parts.append(str(value).rjust(attr.width))
|
|
130
|
+
return "".join(parts)
|
|
131
|
+
|
|
132
|
+
@classmethod
|
|
133
|
+
def from_string(cls, text: str) -> Self:
|
|
134
|
+
"""
|
|
135
|
+
Parse a fixed-width string produced by ``to_string``.
|
|
136
|
+
Raises ``ValueError`` if ``text`` is shorter than the expected width.
|
|
137
|
+
"""
|
|
138
|
+
expected = sum(attr.width for attr in cls._all_attrs.values())
|
|
139
|
+
if len(text) < expected:
|
|
140
|
+
raise ValueError(
|
|
141
|
+
f"{cls.__name__}.from_string expects at least {expected} "
|
|
142
|
+
f"characters, got {len(text)}"
|
|
143
|
+
)
|
|
144
|
+
offset = 0
|
|
145
|
+
kwargs: dict[str, str | Record] = {}
|
|
146
|
+
for name, attr in cls._all_attrs.items():
|
|
147
|
+
w = attr.width
|
|
148
|
+
chunk = text[offset : offset + w]
|
|
149
|
+
if isinstance(attr, RecordField):
|
|
150
|
+
kwargs[name] = attr.record_type.from_string(chunk)
|
|
151
|
+
else:
|
|
152
|
+
kwargs[name] = chunk.strip()
|
|
153
|
+
offset += w
|
|
154
|
+
return cls(**kwargs)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _make_init(all_attrs: dict[str, Field | RecordField]):
|
|
158
|
+
"""Generates a keyword-only __init__ for all declared attrs."""
|
|
159
|
+
attr_names = list(all_attrs.keys())
|
|
160
|
+
|
|
161
|
+
def __init__(self: Record, **kwargs: FieldValue | Record) -> None:
|
|
162
|
+
for name in attr_names:
|
|
163
|
+
value = kwargs.get(name)
|
|
164
|
+
attr = all_attrs[name]
|
|
165
|
+
if value is not None:
|
|
166
|
+
setattr(self, name, value)
|
|
167
|
+
elif isinstance(attr, Field) and attr.default is not None:
|
|
168
|
+
object.__setattr__(self, f"_field_{name}", attr.default)
|
|
169
|
+
# else: leave unset — descriptor returns zero/empty on access
|
|
170
|
+
|
|
171
|
+
__init__.__doc__ = (
|
|
172
|
+
"Args:\n" + "\n".join(f" {n}: {all_attrs[n]}" for n in attr_names)
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
return __init__
|
fixfield/rounding.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
import decimal
|
|
5
|
+
from enum import Enum, auto
|
|
6
|
+
|
|
7
|
+
class RoundingStrategy(Enum):
|
|
8
|
+
ROUND_HALF_UP = auto()
|
|
9
|
+
ROUND_HALF_DOWN = auto()
|
|
10
|
+
ROUND_UP = auto()
|
|
11
|
+
ROUND_DOWN = auto()
|
|
12
|
+
ROUND_CEILING = auto()
|
|
13
|
+
ROUND_FLOOR = auto()
|
|
14
|
+
ROUND_HALF_EVEN = auto()
|
|
15
|
+
ROUND_HALF_ODD = auto()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _round_half_odd(value: decimal.Decimal, places: int) -> decimal.Decimal:
|
|
19
|
+
quantizer = decimal.Decimal(10) ** -places
|
|
20
|
+
floor = value.quantize(quantizer, rounding=decimal.ROUND_FLOOR)
|
|
21
|
+
ceil = value.quantize(quantizer, rounding=decimal.ROUND_CEILING)
|
|
22
|
+
half = quantizer / 2
|
|
23
|
+
if abs(value - floor) == half:
|
|
24
|
+
# exactly halfway — pick whichever of floor/ceil is odd
|
|
25
|
+
return floor if floor % (2 * quantizer) != 0 else ceil
|
|
26
|
+
# not halfway — normal rounding applies
|
|
27
|
+
return value.quantize(quantizer, rounding=decimal.ROUND_HALF_EVEN)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_DECIMAL_MAP = {
|
|
31
|
+
RoundingStrategy.ROUND_HALF_UP: decimal.ROUND_HALF_UP,
|
|
32
|
+
RoundingStrategy.ROUND_HALF_DOWN: decimal.ROUND_HALF_DOWN,
|
|
33
|
+
RoundingStrategy.ROUND_UP: decimal.ROUND_UP,
|
|
34
|
+
RoundingStrategy.ROUND_DOWN: decimal.ROUND_DOWN,
|
|
35
|
+
RoundingStrategy.ROUND_CEILING: decimal.ROUND_CEILING,
|
|
36
|
+
RoundingStrategy.ROUND_FLOOR: decimal.ROUND_FLOOR,
|
|
37
|
+
RoundingStrategy.ROUND_HALF_EVEN: decimal.ROUND_HALF_EVEN,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def apply(value: decimal.Decimal, places: int, strategy: RoundingStrategy) -> decimal.Decimal:
|
|
42
|
+
if strategy is RoundingStrategy.ROUND_HALF_ODD:
|
|
43
|
+
return _round_half_odd(value, places)
|
|
44
|
+
|
|
45
|
+
with decimal.localcontext() as ctx:
|
|
46
|
+
ctx.rounding = _DECIMAL_MAP[strategy]
|
|
47
|
+
return value.quantize(decimal.Decimal(10) ** -places, context=ctx)
|
|
48
|
+
|
fixfield/types.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import decimal
|
|
5
|
+
from fixfield.rounding import RoundingStrategy, apply
|
|
6
|
+
|
|
7
|
+
type _NUMBER = str | float | int | decimal.Decimal
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FieldOverflowError(ValueError):
|
|
11
|
+
"""Raised when a value exceeds the declared integer-digit capacity."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _to_decimal(value: _NUMBER) -> decimal.Decimal:
|
|
15
|
+
if isinstance(value, float):
|
|
16
|
+
return decimal.Decimal(str(value))
|
|
17
|
+
return decimal.Decimal(value) # type: ignore[arg-type]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FixedDecimal:
|
|
21
|
+
places: int
|
|
22
|
+
digits: int | None
|
|
23
|
+
rounding: RoundingStrategy
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
value: _NUMBER,
|
|
28
|
+
places: int = 2,
|
|
29
|
+
rounding: RoundingStrategy = RoundingStrategy.ROUND_HALF_UP,
|
|
30
|
+
digits: int | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
self.places = places
|
|
33
|
+
self.rounding = rounding
|
|
34
|
+
self.digits = digits
|
|
35
|
+
rounded = apply(_to_decimal(value), places, rounding)
|
|
36
|
+
if digits is not None:
|
|
37
|
+
limit = decimal.Decimal(10) ** digits
|
|
38
|
+
if abs(rounded) >= limit:
|
|
39
|
+
raise FieldOverflowError(
|
|
40
|
+
f"value {rounded} exceeds {digits} integer digits"
|
|
41
|
+
)
|
|
42
|
+
self.value = rounded
|
|
43
|
+
|
|
44
|
+
def _new(self, value: decimal.Decimal) -> FixedDecimal:
|
|
45
|
+
return FixedDecimal(value, self.places, self.rounding, self.digits)
|
|
46
|
+
|
|
47
|
+
def _coerce(self, other: FixedDecimal | _NUMBER) -> FixedDecimal:
|
|
48
|
+
if not isinstance(other, FixedDecimal):
|
|
49
|
+
return FixedDecimal(other, self.places, self.rounding, self.digits)
|
|
50
|
+
return other
|
|
51
|
+
|
|
52
|
+
# Arithmetic --------------------------------------------------------------
|
|
53
|
+
# Convention: the LEFT operand's precision (places, rounding, digits)
|
|
54
|
+
# always governs the result. The right operand's rounding is ignored.
|
|
55
|
+
# This mirrors COBOL's COMPUTE statement where the receiving field
|
|
56
|
+
# defines the result's precision.
|
|
57
|
+
|
|
58
|
+
def __add__(self, other: FixedDecimal | _NUMBER) -> FixedDecimal:
|
|
59
|
+
return self._new(self.value + self._coerce(other).value)
|
|
60
|
+
|
|
61
|
+
def __sub__(self, other: FixedDecimal | _NUMBER) -> FixedDecimal:
|
|
62
|
+
return self._new(self.value - self._coerce(other).value)
|
|
63
|
+
|
|
64
|
+
def __mul__(self, other: FixedDecimal | _NUMBER) -> FixedDecimal:
|
|
65
|
+
return self._new(self.value * self._coerce(other).value)
|
|
66
|
+
|
|
67
|
+
def __truediv__(self, other: FixedDecimal | _NUMBER) -> FixedDecimal:
|
|
68
|
+
return self._new(self.value / self._coerce(other).value)
|
|
69
|
+
|
|
70
|
+
# Reverse arithmetic — scalars on the left (e.g. 2 * price) ---------------
|
|
71
|
+
# Left operand's precision is unknown so we use self's precision.
|
|
72
|
+
|
|
73
|
+
def __radd__(self, other: _NUMBER) -> FixedDecimal:
|
|
74
|
+
return self._new(_to_decimal(other) + self.value)
|
|
75
|
+
|
|
76
|
+
def __rsub__(self, other: _NUMBER) -> FixedDecimal:
|
|
77
|
+
return self._new(_to_decimal(other) - self.value)
|
|
78
|
+
|
|
79
|
+
def __rmul__(self, other: _NUMBER) -> FixedDecimal:
|
|
80
|
+
return self._new(_to_decimal(other) * self.value)
|
|
81
|
+
|
|
82
|
+
def __rtruediv__(self, other: _NUMBER) -> FixedDecimal:
|
|
83
|
+
return self._new(_to_decimal(other) / self.value)
|
|
84
|
+
|
|
85
|
+
def __neg__(self) -> FixedDecimal:
|
|
86
|
+
return self._new(-self.value)
|
|
87
|
+
|
|
88
|
+
def __abs__(self) -> FixedDecimal:
|
|
89
|
+
return self._new(abs(self.value))
|
|
90
|
+
|
|
91
|
+
# Copy / replace ----------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
def copy(self) -> FixedDecimal:
|
|
94
|
+
"""Return an identical copy."""
|
|
95
|
+
return self._new(self.value)
|
|
96
|
+
|
|
97
|
+
def replace(
|
|
98
|
+
self,
|
|
99
|
+
*,
|
|
100
|
+
places: int | None = None,
|
|
101
|
+
rounding: RoundingStrategy | None = None,
|
|
102
|
+
digits: int | None = ..., # type: ignore[assignment]
|
|
103
|
+
) -> FixedDecimal:
|
|
104
|
+
"""
|
|
105
|
+
Return a new ``FixedDecimal`` with selected attributes changed.
|
|
106
|
+
The value is re-rounded to the new precision.
|
|
107
|
+
|
|
108
|
+
Pass ``digits=None`` explicitly to remove the digit cap.
|
|
109
|
+
"""
|
|
110
|
+
new_places = self.places if places is None else places
|
|
111
|
+
new_rounding = self.rounding if rounding is None else rounding
|
|
112
|
+
new_digits = self.digits if digits is ... else digits
|
|
113
|
+
return FixedDecimal(self.value, new_places, new_rounding, new_digits)
|
|
114
|
+
|
|
115
|
+
# Comparisons -------------------------------------------------------------
|
|
116
|
+
# FixedDecimal vs FixedDecimal: precision-sensitive (value AND places must match).
|
|
117
|
+
# FixedDecimal vs raw scalar: value-only, so FixedDecimal("1.00", places=2) == 1
|
|
118
|
+
# is True regardless of places. This is intentional — raw scalars carry no
|
|
119
|
+
# precision information.
|
|
120
|
+
|
|
121
|
+
def __eq__(self, other: object) -> bool:
|
|
122
|
+
if isinstance(other, FixedDecimal):
|
|
123
|
+
return self.value == other.value and self.places == other.places
|
|
124
|
+
if isinstance(other, (str, int, float, decimal.Decimal)):
|
|
125
|
+
return self.value == _to_decimal(other)
|
|
126
|
+
return NotImplemented
|
|
127
|
+
|
|
128
|
+
def __ne__(self, other: object) -> bool:
|
|
129
|
+
result = self.__eq__(other)
|
|
130
|
+
if result is NotImplemented:
|
|
131
|
+
return result
|
|
132
|
+
return not result
|
|
133
|
+
|
|
134
|
+
def __lt__(self, other: FixedDecimal | _NUMBER) -> bool:
|
|
135
|
+
return self.value < self._coerce(other).value
|
|
136
|
+
|
|
137
|
+
def __le__(self, other: FixedDecimal | _NUMBER) -> bool:
|
|
138
|
+
return self.value <= self._coerce(other).value
|
|
139
|
+
|
|
140
|
+
def __gt__(self, other: FixedDecimal | _NUMBER) -> bool:
|
|
141
|
+
return self.value > self._coerce(other).value
|
|
142
|
+
|
|
143
|
+
def __ge__(self, other: FixedDecimal | _NUMBER) -> bool:
|
|
144
|
+
return self.value >= self._coerce(other).value
|
|
145
|
+
|
|
146
|
+
def __hash__(self) -> int:
|
|
147
|
+
# Includes places so precision-different instances with equal values
|
|
148
|
+
# hash differently — consistent with precision-sensitive __eq__.
|
|
149
|
+
return hash((self.value, self.places))
|
|
150
|
+
|
|
151
|
+
# Representation ----------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
def __repr__(self) -> str:
|
|
154
|
+
return (
|
|
155
|
+
f"FixedDecimal(value={self.value}, places={self.places}, "
|
|
156
|
+
f"rounding={self.rounding})"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def __str__(self) -> str:
|
|
160
|
+
return f"{self.value:.{self.places}f}"
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fixfield
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Fixed-decimal arithmetic with per-field precision enforcement
|
|
5
|
+
Author-email: Charles Reilly <charlesreilly0@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: cobol,decimal,finance,fixed-point,precision
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.14
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# fixfield
|
|
21
|
+
|
|
22
|
+
Fixed-decimal arithmetic for Python with per-field precision enforcement.
|
|
23
|
+
|
|
24
|
+
Inspired by COBOL's `PIC` clause — declare precision once on the field, and it is enforced automatically on every assignment and arithmetic result.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
uv add fixfield
|
|
32
|
+
# or
|
|
33
|
+
pip install fixfield
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Quick Start
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from fixfield import Record, Field, RoundingStrategy
|
|
42
|
+
|
|
43
|
+
class Invoice(Record):
|
|
44
|
+
price = Field(places=2)
|
|
45
|
+
tax_rate = Field(places=4)
|
|
46
|
+
tax = Field(places=2)
|
|
47
|
+
total = Field(places=2)
|
|
48
|
+
|
|
49
|
+
inv = Invoice(price="19.99", tax_rate="0.0825")
|
|
50
|
+
inv.tax = inv.price * inv.tax_rate # 1.649175 → rounded to 1.65
|
|
51
|
+
inv.total = inv.price + inv.tax # 21.64
|
|
52
|
+
|
|
53
|
+
print(inv.total) # "21.64"
|
|
54
|
+
print(repr(inv)) # Invoice(price=19.99, tax_rate=0.0825, tax=1.65, total=21.64)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Why Not Just Use `decimal.Decimal`?
|
|
60
|
+
|
|
61
|
+
| | `decimal.Decimal` | `fixfield` |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| Precision location | Global context or per `.quantize()` call | Declared on the field, enforced automatically |
|
|
64
|
+
| Rounding enforcement | Manual on every result | Automatic on every assignment |
|
|
65
|
+
| Per-field rounding strategy | Manual | Declarative |
|
|
66
|
+
| Domain modelling | Plain values | Named record schema |
|
|
67
|
+
|
|
68
|
+
With `decimal` you must call `.quantize()` on every result or silently lose precision. With `fixfield` the field declaration is the single source of truth.
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Core Concepts
|
|
73
|
+
|
|
74
|
+
### `FixedDecimal`
|
|
75
|
+
|
|
76
|
+
A scalar decimal value locked to a declared precision.
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from fixfield import FixedDecimal, RoundingStrategy
|
|
80
|
+
|
|
81
|
+
price = FixedDecimal("19.999", places=2)
|
|
82
|
+
str(price) # "20.00" — rounded on construction
|
|
83
|
+
|
|
84
|
+
# Arithmetic preserves left operand's precision
|
|
85
|
+
result = price + FixedDecimal("1.005", places=4)
|
|
86
|
+
result.places # 2 (left operand wins)
|
|
87
|
+
str(result) # "21.00"
|
|
88
|
+
|
|
89
|
+
# Comparisons work naturally
|
|
90
|
+
price > FixedDecimal("10.00") # True
|
|
91
|
+
price == "20.00" # True
|
|
92
|
+
|
|
93
|
+
# Unary operators
|
|
94
|
+
str(-price) # "-20.00"
|
|
95
|
+
str(abs(-price)) # "20.00"
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Float inputs are automatically converted via `str` to avoid binary imprecision:
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
FixedDecimal(0.1 + 0.2, places=2) # "0.30" not "0.30000000000000004"
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### `Field`
|
|
105
|
+
|
|
106
|
+
A descriptor that enforces precision on a class attribute. Used inside a `Record`.
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from fixfield import Field, RoundingStrategy
|
|
110
|
+
|
|
111
|
+
price = Field(places=2) # default ROUND_HALF_UP
|
|
112
|
+
tax_rate = Field(places=4, rounding=RoundingStrategy.ROUND_FLOOR)
|
|
113
|
+
total = Field(places=2, default="0.00")
|
|
114
|
+
capped = Field(places=2, digits=5) # max 99999.99
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
| Parameter | Default | Description |
|
|
118
|
+
|---|---|---|
|
|
119
|
+
| `places` | `2` | Decimal places to keep |
|
|
120
|
+
| `rounding` | `ROUND_HALF_UP` | Rounding strategy on assignment |
|
|
121
|
+
| `default` | `None` | Default value (zero if not set) |
|
|
122
|
+
| `digits` | `None` | Max integer digits — raises `FieldOverflowError` if exceeded |
|
|
123
|
+
|
|
124
|
+
### `Record`
|
|
125
|
+
|
|
126
|
+
A structured collection of `Field` descriptors. Generates `__init__`, `__repr__`, and `__eq__` automatically.
|
|
127
|
+
|
|
128
|
+
> **Field ordering** relies on Python's guaranteed `dict` insertion order (Python 3.7+). Fields are serialised to and from fixed-width strings in the order they are declared in the class body.
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
from fixfield import Record, Field
|
|
132
|
+
|
|
133
|
+
class Payment(Record):
|
|
134
|
+
amount = Field(places=2, digits=7) # up to 9999999.99
|
|
135
|
+
fee = Field(places=2, digits=4)
|
|
136
|
+
net = Field(places=2, digits=7)
|
|
137
|
+
|
|
138
|
+
p = Payment(amount="1000.00", fee="2.50")
|
|
139
|
+
p.net = p.amount - p.fee
|
|
140
|
+
|
|
141
|
+
print(p) # Payment(amount=1000.00, fee=2.50, net=997.50)
|
|
142
|
+
p.to_dict() # {"amount": FixedDecimal(...), "fee": ..., "net": ...}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### `RecordField`
|
|
146
|
+
|
|
147
|
+
Embed a nested `Record` as a field inside another `Record`. The nested record's fields participate in `to_string`/`from_string` as a contiguous block.
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
from fixfield import Record, Field, RecordField
|
|
151
|
+
|
|
152
|
+
class Address(Record):
|
|
153
|
+
zip_code = Field(places=0, digits=5)
|
|
154
|
+
state = Field(places=0, digits=2)
|
|
155
|
+
|
|
156
|
+
class Customer(Record):
|
|
157
|
+
customer_id = Field(places=0, digits=6)
|
|
158
|
+
address = RecordField(Address)
|
|
159
|
+
|
|
160
|
+
c = Customer(customer_id="42", address=Address(zip_code="90210", state="6"))
|
|
161
|
+
line = c.to_string() # " 42 90210 6"
|
|
162
|
+
parsed = Customer.from_string(line)
|
|
163
|
+
str(parsed.address.zip_code) # "90210"
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`RecordField` is generic: `RecordField[Address]` so your IDE knows that `c.address` is an `Address`, not just a `Record`.
|
|
167
|
+
|
|
168
|
+
### `RoundingStrategy`
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
from fixfield import RoundingStrategy
|
|
172
|
+
|
|
173
|
+
RoundingStrategy.ROUND_HALF_UP # 2.5 → 3 (COBOL ROUNDED default)
|
|
174
|
+
RoundingStrategy.ROUND_HALF_DOWN # 2.5 → 2
|
|
175
|
+
RoundingStrategy.ROUND_HALF_EVEN # 2.5 → 2, 3.5 → 4 (banker's rounding)
|
|
176
|
+
RoundingStrategy.ROUND_HALF_ODD # 2.5 → 3, 3.5 → 3
|
|
177
|
+
RoundingStrategy.ROUND_UP # always away from zero
|
|
178
|
+
RoundingStrategy.ROUND_DOWN # always toward zero (truncate)
|
|
179
|
+
RoundingStrategy.ROUND_CEILING # toward +∞
|
|
180
|
+
RoundingStrategy.ROUND_FLOOR # toward -∞
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## Fixed-Width Serialization
|
|
186
|
+
|
|
187
|
+
When every `Field` has `digits` set, records can be serialized to and from fixed-width strings — useful for mainframe flat files and legacy EDI formats.
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
class CustomerRecord(Record):
|
|
191
|
+
customer_id = Field(places=0, digits=6) # 7 chars: " 123456"
|
|
192
|
+
balance = Field(places=2, digits=8) # 11 chars: " 99999.99"
|
|
193
|
+
|
|
194
|
+
rec = CustomerRecord(customer_id="123456", balance="99999.99")
|
|
195
|
+
line = rec.to_string() # " 123456 99999.99"
|
|
196
|
+
|
|
197
|
+
parsed = CustomerRecord.from_string(line)
|
|
198
|
+
parsed.balance == rec.balance # True
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Field width formula: `1 (sign) + digits + (1 + places if places > 0 else 0)`
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## Overflow Protection
|
|
206
|
+
|
|
207
|
+
```python
|
|
208
|
+
from fixfield import Field, Record, FieldOverflowError
|
|
209
|
+
|
|
210
|
+
class Account(Record):
|
|
211
|
+
balance = Field(places=2, digits=5) # max 99999.99
|
|
212
|
+
|
|
213
|
+
a = Account()
|
|
214
|
+
a.balance = "99999.99" # ok
|
|
215
|
+
a.balance = "100000.00" # raises FieldOverflowError
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
## Dataclass Integration
|
|
221
|
+
|
|
222
|
+
For users who prefer `@dataclass`, use `FixedDecimal` directly and coerce values in `__post_init__`:
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
from dataclasses import dataclass, field
|
|
226
|
+
from fixfield import FixedDecimal, RoundingStrategy
|
|
227
|
+
|
|
228
|
+
@dataclass
|
|
229
|
+
class LineItem:
|
|
230
|
+
price: FixedDecimal = field(default_factory=lambda: FixedDecimal(0, places=2))
|
|
231
|
+
quantity: int = 1
|
|
232
|
+
|
|
233
|
+
def __post_init__(self):
|
|
234
|
+
if not isinstance(self.price, FixedDecimal):
|
|
235
|
+
self.price = FixedDecimal(self.price, places=2)
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def total(self) -> FixedDecimal:
|
|
239
|
+
return self.price * self.quantity
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
For full precision enforcement without `__post_init__` boilerplate, use `Record` instead.
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
## License
|
|
247
|
+
|
|
248
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
fixfield/__init__.py,sha256=rYGhkQtdSvgnwxPRKKBPpRQnfdBrhS_YO7frJZTxTA0,391
|
|
2
|
+
fixfield/field.py,sha256=GW_tRcwp1s19F1riMxAXKBSoHKPAWRButrB0fRaEJqQ,2399
|
|
3
|
+
fixfield/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
fixfield/record.py,sha256=yhqW0D9YBnTo4D1IYBxCOHZCxjgp2wpuHe-9uEzbOcc,6078
|
|
5
|
+
fixfield/rounding.py,sha256=QeMNDXSLcOvuO_8CpQkgqQzuZa2Pc5vZGgPDITHSBu0,1683
|
|
6
|
+
fixfield/types.py,sha256=xSMa8qxw0s4BKIRt4YIE4Gz05aDEGfyD5TlhqG2thGU,5993
|
|
7
|
+
fixfield-0.1.0.dist-info/METADATA,sha256=lM7kosD1t68r308eyGyd4sXNKP2ETcYGYp_xtjZr1A4,7402
|
|
8
|
+
fixfield-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
9
|
+
fixfield-0.1.0.dist-info/licenses/LICENSE,sha256=vKYEaMk4m1-gPk-kEcwDlFEzARj6t1hj4lhumZC3uTk,1071
|
|
10
|
+
fixfield-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Charles Reilly
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|