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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.