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.
@@ -0,0 +1,10 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
@@ -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.
@@ -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,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