fixfield 0.1.0__tar.gz
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-0.1.0/.gitignore +10 -0
- fixfield-0.1.0/.python-version +1 -0
- fixfield-0.1.0/CHANGELOG.md +32 -0
- fixfield-0.1.0/LICENSE +21 -0
- fixfield-0.1.0/PKG-INFO +248 -0
- fixfield-0.1.0/README.md +229 -0
- fixfield-0.1.0/pyproject.toml +35 -0
- fixfield-0.1.0/src/fixfield/__init__.py +17 -0
- fixfield-0.1.0/src/fixfield/field.py +73 -0
- fixfield-0.1.0/src/fixfield/py.typed +0 -0
- fixfield-0.1.0/src/fixfield/record.py +175 -0
- fixfield-0.1.0/src/fixfield/rounding.py +48 -0
- fixfield-0.1.0/src/fixfield/types.py +160 -0
- fixfield-0.1.0/tests/test_extras.py +268 -0
- fixfield-0.1.0/tests/test_field_record.py +189 -0
- fixfield-0.1.0/tests/test_record_field.py +152 -0
- fixfield-0.1.0/tests/test_rounding.py +56 -0
- fixfield-0.1.0/tests/test_serialization.py +123 -0
- fixfield-0.1.0/tests/test_types.py +113 -0
- fixfield-0.1.0/uv.lock +79 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.14
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `fixfield` will be documented here.
|
|
4
|
+
|
|
5
|
+
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|
6
|
+
Versioning follows [Semantic Versioning](https://semver.org/).
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## [0.1.0] — 2026-05-08
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `FixedDecimal` — scalar fixed-decimal type with per-instance precision and rounding
|
|
14
|
+
- `RoundingStrategy` — enum of 8 rounding modes including `ROUND_HALF_ODD`
|
|
15
|
+
- `Field` — descriptor for declaring fixed-decimal fields on a `Record`
|
|
16
|
+
- `places`, `digits`, `rounding`, `default` parameters
|
|
17
|
+
- `width` property for fixed-width serialization
|
|
18
|
+
- `Record` — base class for structured groups of `Field` descriptors
|
|
19
|
+
- Auto-generated `__init__`, `__repr__`, `__eq__`
|
|
20
|
+
- `to_dict()`, `to_string()`, `from_string()`
|
|
21
|
+
- `RecordField[T]` — generic descriptor for embedding a nested `Record` as a field
|
|
22
|
+
- `width` delegates to nested record's total width
|
|
23
|
+
- Participates in `to_string()` / `from_string()` as a contiguous block
|
|
24
|
+
- `FieldOverflowError` — raised when a value exceeds declared integer-digit capacity
|
|
25
|
+
- `FieldValue` — public type alias for anything assignable to a `Field`
|
|
26
|
+
- Full arithmetic on `FixedDecimal`: `+`, `-`, `*`, `/`, unary `-`, `abs()`
|
|
27
|
+
- Reverse arithmetic operators: `__radd__`, `__rsub__`, `__rmul__`, `__rtruediv__`
|
|
28
|
+
- Full comparison protocol: `==`, `!=`, `<`, `<=`, `>`, `>=`
|
|
29
|
+
- `FixedDecimal.copy()` and `FixedDecimal.replace()` for non-destructive modification
|
|
30
|
+
- Fixed-width serialization: `Record.to_string()` / `Record.from_string()`
|
|
31
|
+
- `py.typed` marker for PEP 561 compliance
|
|
32
|
+
- Float input safety — floats converted via `str()` to avoid binary imprecision
|
fixfield-0.1.0/LICENSE
ADDED
|
@@ -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.
|
fixfield-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
fixfield-0.1.0/README.md
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# fixfield
|
|
2
|
+
|
|
3
|
+
Fixed-decimal arithmetic for Python with per-field precision enforcement.
|
|
4
|
+
|
|
5
|
+
Inspired by COBOL's `PIC` clause — declare precision once on the field, and it is enforced automatically on every assignment and arithmetic result.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
uv add fixfield
|
|
13
|
+
# or
|
|
14
|
+
pip install fixfield
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Quick Start
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from fixfield import Record, Field, RoundingStrategy
|
|
23
|
+
|
|
24
|
+
class Invoice(Record):
|
|
25
|
+
price = Field(places=2)
|
|
26
|
+
tax_rate = Field(places=4)
|
|
27
|
+
tax = Field(places=2)
|
|
28
|
+
total = Field(places=2)
|
|
29
|
+
|
|
30
|
+
inv = Invoice(price="19.99", tax_rate="0.0825")
|
|
31
|
+
inv.tax = inv.price * inv.tax_rate # 1.649175 → rounded to 1.65
|
|
32
|
+
inv.total = inv.price + inv.tax # 21.64
|
|
33
|
+
|
|
34
|
+
print(inv.total) # "21.64"
|
|
35
|
+
print(repr(inv)) # Invoice(price=19.99, tax_rate=0.0825, tax=1.65, total=21.64)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Why Not Just Use `decimal.Decimal`?
|
|
41
|
+
|
|
42
|
+
| | `decimal.Decimal` | `fixfield` |
|
|
43
|
+
|---|---|---|
|
|
44
|
+
| Precision location | Global context or per `.quantize()` call | Declared on the field, enforced automatically |
|
|
45
|
+
| Rounding enforcement | Manual on every result | Automatic on every assignment |
|
|
46
|
+
| Per-field rounding strategy | Manual | Declarative |
|
|
47
|
+
| Domain modelling | Plain values | Named record schema |
|
|
48
|
+
|
|
49
|
+
With `decimal` you must call `.quantize()` on every result or silently lose precision. With `fixfield` the field declaration is the single source of truth.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Core Concepts
|
|
54
|
+
|
|
55
|
+
### `FixedDecimal`
|
|
56
|
+
|
|
57
|
+
A scalar decimal value locked to a declared precision.
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from fixfield import FixedDecimal, RoundingStrategy
|
|
61
|
+
|
|
62
|
+
price = FixedDecimal("19.999", places=2)
|
|
63
|
+
str(price) # "20.00" — rounded on construction
|
|
64
|
+
|
|
65
|
+
# Arithmetic preserves left operand's precision
|
|
66
|
+
result = price + FixedDecimal("1.005", places=4)
|
|
67
|
+
result.places # 2 (left operand wins)
|
|
68
|
+
str(result) # "21.00"
|
|
69
|
+
|
|
70
|
+
# Comparisons work naturally
|
|
71
|
+
price > FixedDecimal("10.00") # True
|
|
72
|
+
price == "20.00" # True
|
|
73
|
+
|
|
74
|
+
# Unary operators
|
|
75
|
+
str(-price) # "-20.00"
|
|
76
|
+
str(abs(-price)) # "20.00"
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Float inputs are automatically converted via `str` to avoid binary imprecision:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
FixedDecimal(0.1 + 0.2, places=2) # "0.30" not "0.30000000000000004"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### `Field`
|
|
86
|
+
|
|
87
|
+
A descriptor that enforces precision on a class attribute. Used inside a `Record`.
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from fixfield import Field, RoundingStrategy
|
|
91
|
+
|
|
92
|
+
price = Field(places=2) # default ROUND_HALF_UP
|
|
93
|
+
tax_rate = Field(places=4, rounding=RoundingStrategy.ROUND_FLOOR)
|
|
94
|
+
total = Field(places=2, default="0.00")
|
|
95
|
+
capped = Field(places=2, digits=5) # max 99999.99
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
| Parameter | Default | Description |
|
|
99
|
+
|---|---|---|
|
|
100
|
+
| `places` | `2` | Decimal places to keep |
|
|
101
|
+
| `rounding` | `ROUND_HALF_UP` | Rounding strategy on assignment |
|
|
102
|
+
| `default` | `None` | Default value (zero if not set) |
|
|
103
|
+
| `digits` | `None` | Max integer digits — raises `FieldOverflowError` if exceeded |
|
|
104
|
+
|
|
105
|
+
### `Record`
|
|
106
|
+
|
|
107
|
+
A structured collection of `Field` descriptors. Generates `__init__`, `__repr__`, and `__eq__` automatically.
|
|
108
|
+
|
|
109
|
+
> **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.
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
from fixfield import Record, Field
|
|
113
|
+
|
|
114
|
+
class Payment(Record):
|
|
115
|
+
amount = Field(places=2, digits=7) # up to 9999999.99
|
|
116
|
+
fee = Field(places=2, digits=4)
|
|
117
|
+
net = Field(places=2, digits=7)
|
|
118
|
+
|
|
119
|
+
p = Payment(amount="1000.00", fee="2.50")
|
|
120
|
+
p.net = p.amount - p.fee
|
|
121
|
+
|
|
122
|
+
print(p) # Payment(amount=1000.00, fee=2.50, net=997.50)
|
|
123
|
+
p.to_dict() # {"amount": FixedDecimal(...), "fee": ..., "net": ...}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### `RecordField`
|
|
127
|
+
|
|
128
|
+
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.
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
from fixfield import Record, Field, RecordField
|
|
132
|
+
|
|
133
|
+
class Address(Record):
|
|
134
|
+
zip_code = Field(places=0, digits=5)
|
|
135
|
+
state = Field(places=0, digits=2)
|
|
136
|
+
|
|
137
|
+
class Customer(Record):
|
|
138
|
+
customer_id = Field(places=0, digits=6)
|
|
139
|
+
address = RecordField(Address)
|
|
140
|
+
|
|
141
|
+
c = Customer(customer_id="42", address=Address(zip_code="90210", state="6"))
|
|
142
|
+
line = c.to_string() # " 42 90210 6"
|
|
143
|
+
parsed = Customer.from_string(line)
|
|
144
|
+
str(parsed.address.zip_code) # "90210"
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`RecordField` is generic: `RecordField[Address]` so your IDE knows that `c.address` is an `Address`, not just a `Record`.
|
|
148
|
+
|
|
149
|
+
### `RoundingStrategy`
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
from fixfield import RoundingStrategy
|
|
153
|
+
|
|
154
|
+
RoundingStrategy.ROUND_HALF_UP # 2.5 → 3 (COBOL ROUNDED default)
|
|
155
|
+
RoundingStrategy.ROUND_HALF_DOWN # 2.5 → 2
|
|
156
|
+
RoundingStrategy.ROUND_HALF_EVEN # 2.5 → 2, 3.5 → 4 (banker's rounding)
|
|
157
|
+
RoundingStrategy.ROUND_HALF_ODD # 2.5 → 3, 3.5 → 3
|
|
158
|
+
RoundingStrategy.ROUND_UP # always away from zero
|
|
159
|
+
RoundingStrategy.ROUND_DOWN # always toward zero (truncate)
|
|
160
|
+
RoundingStrategy.ROUND_CEILING # toward +∞
|
|
161
|
+
RoundingStrategy.ROUND_FLOOR # toward -∞
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## Fixed-Width Serialization
|
|
167
|
+
|
|
168
|
+
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.
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
class CustomerRecord(Record):
|
|
172
|
+
customer_id = Field(places=0, digits=6) # 7 chars: " 123456"
|
|
173
|
+
balance = Field(places=2, digits=8) # 11 chars: " 99999.99"
|
|
174
|
+
|
|
175
|
+
rec = CustomerRecord(customer_id="123456", balance="99999.99")
|
|
176
|
+
line = rec.to_string() # " 123456 99999.99"
|
|
177
|
+
|
|
178
|
+
parsed = CustomerRecord.from_string(line)
|
|
179
|
+
parsed.balance == rec.balance # True
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Field width formula: `1 (sign) + digits + (1 + places if places > 0 else 0)`
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## Overflow Protection
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
from fixfield import Field, Record, FieldOverflowError
|
|
190
|
+
|
|
191
|
+
class Account(Record):
|
|
192
|
+
balance = Field(places=2, digits=5) # max 99999.99
|
|
193
|
+
|
|
194
|
+
a = Account()
|
|
195
|
+
a.balance = "99999.99" # ok
|
|
196
|
+
a.balance = "100000.00" # raises FieldOverflowError
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## Dataclass Integration
|
|
202
|
+
|
|
203
|
+
For users who prefer `@dataclass`, use `FixedDecimal` directly and coerce values in `__post_init__`:
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
from dataclasses import dataclass, field
|
|
207
|
+
from fixfield import FixedDecimal, RoundingStrategy
|
|
208
|
+
|
|
209
|
+
@dataclass
|
|
210
|
+
class LineItem:
|
|
211
|
+
price: FixedDecimal = field(default_factory=lambda: FixedDecimal(0, places=2))
|
|
212
|
+
quantity: int = 1
|
|
213
|
+
|
|
214
|
+
def __post_init__(self):
|
|
215
|
+
if not isinstance(self.price, FixedDecimal):
|
|
216
|
+
self.price = FixedDecimal(self.price, places=2)
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def total(self) -> FixedDecimal:
|
|
220
|
+
return self.price * self.quantity
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
For full precision enforcement without `__post_init__` boilerplate, use `Record` instead.
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## License
|
|
228
|
+
|
|
229
|
+
MIT
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "fixfield"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Fixed-decimal arithmetic with per-field precision enforcement"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.14"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Charles Reilly", email = "charlesreilly0@gmail.com" },
|
|
10
|
+
]
|
|
11
|
+
keywords = ["decimal", "fixed-point", "cobol", "finance", "precision"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.14",
|
|
18
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
19
|
+
"Topic :: Office/Business :: Financial",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
dependencies = []
|
|
23
|
+
|
|
24
|
+
[tool.hatch.build.targets.wheel]
|
|
25
|
+
packages = ["src/fixfield"]
|
|
26
|
+
|
|
27
|
+
[build-system]
|
|
28
|
+
requires = ["hatchling"]
|
|
29
|
+
build-backend = "hatchling.build"
|
|
30
|
+
|
|
31
|
+
[dependency-groups]
|
|
32
|
+
dev = [
|
|
33
|
+
"pytest>=9.0.3",
|
|
34
|
+
]
|
|
35
|
+
|
|
@@ -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
|
+
]
|
|
@@ -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
|
+
)
|
|
File without changes
|