Skip to main content

Fixed-decimal arithmetic with per-field precision enforcement

Project description

fixfield

Fixed-decimal arithmetic for Python with per-field precision enforcement.

Inspired by COBOL's PIC clause — declare precision once on the field, and it is enforced automatically on every assignment and arithmetic result.


Installation

uv add fixfield
# or
pip install fixfield

Quick Start

from fixfield import Record, Field, RoundingStrategy

class Invoice(Record):
    price    = Field(places=2)
    tax_rate = Field(places=4)
    tax      = Field(places=2)
    total    = Field(places=2)

inv = Invoice(price="19.99", tax_rate="0.0825")
inv.tax   = inv.price * inv.tax_rate   # 1.649175 → rounded to 1.65
inv.total = inv.price + inv.tax        # 21.64

print(inv.total)   # "21.64"
print(repr(inv))   # Invoice(price=19.99, tax_rate=0.0825, tax=1.65, total=21.64)

Why Not Just Use decimal.Decimal?

decimal.Decimal fixfield
Precision location Global context or per .quantize() call Declared on the field, enforced automatically
Rounding enforcement Manual on every result Automatic on every assignment
Per-field rounding strategy Manual Declarative
Domain modelling Plain values Named record schema

With decimal you must call .quantize() on every result or silently lose precision. With fixfield the field declaration is the single source of truth.


Core Concepts

FixedDecimal

A scalar decimal value locked to a declared precision.

from fixfield import FixedDecimal, RoundingStrategy

price = FixedDecimal("19.999", places=2)
str(price)   # "20.00" — rounded on construction

# Arithmetic preserves left operand's precision
result = price + FixedDecimal("1.005", places=4)
result.places     # 2  (left operand wins)
str(result)       # "21.00"

# Comparisons work naturally
price > FixedDecimal("10.00")   # True
price == "20.00"                # True

# Unary operators
str(-price)        # "-20.00"
str(abs(-price))   # "20.00"

Float inputs are automatically converted via str to avoid binary imprecision:

FixedDecimal(0.1 + 0.2, places=2)   # "0.30" not "0.30000000000000004"

Field

A descriptor that enforces precision on a class attribute. Used inside a Record.

from fixfield import Field, RoundingStrategy

price    = Field(places=2)                                    # default ROUND_HALF_UP
tax_rate = Field(places=4, rounding=RoundingStrategy.ROUND_FLOOR)
total    = Field(places=2, default="0.00")
capped   = Field(places=2, digits=5)                          # max 99999.99
Parameter Default Description
places 2 Decimal places to keep
rounding ROUND_HALF_UP Rounding strategy on assignment
default None Default value (zero if not set)
digits None Max integer digits — raises FieldOverflowError if exceeded

Record

A structured collection of Field descriptors. Generates __init__, __repr__, and __eq__ automatically.

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.

from fixfield import Record, Field

class Payment(Record):
    amount     = Field(places=2, digits=7)   # up to 9999999.99
    fee        = Field(places=2, digits=4)
    net        = Field(places=2, digits=7)

p = Payment(amount="1000.00", fee="2.50")
p.net = p.amount - p.fee

print(p)          # Payment(amount=1000.00, fee=2.50, net=997.50)
p.to_dict()       # {"amount": FixedDecimal(...), "fee": ..., "net": ...}

RecordField

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.

from fixfield import Record, Field, RecordField

class Address(Record):
    zip_code = Field(places=0, digits=5)
    state    = Field(places=0, digits=2)

class Customer(Record):
    customer_id = Field(places=0, digits=6)
    address     = RecordField(Address)

c = Customer(customer_id="42", address=Address(zip_code="90210", state="6"))
line = c.to_string()                        # "    42 90210 6"
parsed = Customer.from_string(line)
str(parsed.address.zip_code)                # "90210"

RecordField is generic: RecordField[Address] so your IDE knows that c.address is an Address, not just a Record.

RoundingStrategy

from fixfield import RoundingStrategy

RoundingStrategy.ROUND_HALF_UP    # 2.5 → 3  (COBOL ROUNDED default)
RoundingStrategy.ROUND_HALF_DOWN  # 2.5 → 2
RoundingStrategy.ROUND_HALF_EVEN  # 2.5 → 2, 3.5 → 4  (banker's rounding)
RoundingStrategy.ROUND_HALF_ODD   # 2.5 → 3, 3.5 → 3
RoundingStrategy.ROUND_UP         # always away from zero
RoundingStrategy.ROUND_DOWN       # always toward zero (truncate)
RoundingStrategy.ROUND_CEILING    # toward +∞
RoundingStrategy.ROUND_FLOOR      # toward -∞

Fixed-Width Serialization

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.

class CustomerRecord(Record):
    customer_id = Field(places=0, digits=6)    # 7 chars:  " 123456"
    balance     = Field(places=2, digits=8)    # 11 chars: "  99999.99"

rec = CustomerRecord(customer_id="123456", balance="99999.99")
line = rec.to_string()             # " 123456  99999.99"

parsed = CustomerRecord.from_string(line)
parsed.balance == rec.balance      # True

Field width formula: 1 (sign) + digits + (1 + places if places > 0 else 0)


Overflow Protection

from fixfield import Field, Record, FieldOverflowError

class Account(Record):
    balance = Field(places=2, digits=5)   # max 99999.99

a = Account()
a.balance = "99999.99"   # ok
a.balance = "100000.00"  # raises FieldOverflowError

Dataclass Integration

For users who prefer @dataclass, use FixedDecimal directly and coerce values in __post_init__:

from dataclasses import dataclass, field
from fixfield import FixedDecimal, RoundingStrategy

@dataclass
class LineItem:
    price:    FixedDecimal = field(default_factory=lambda: FixedDecimal(0, places=2))
    quantity: int = 1

    def __post_init__(self):
        if not isinstance(self.price, FixedDecimal):
            self.price = FixedDecimal(self.price, places=2)

    @property
    def total(self) -> FixedDecimal:
        return self.price * self.quantity

For full precision enforcement without __post_init__ boilerplate, use Record instead.


License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

fixfield-0.1.0.tar.gz (16.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

fixfield-0.1.0-py3-none-any.whl (10.4 kB view details)

Uploaded Python 3

File details

Details for the file fixfield-0.1.0.tar.gz.

File metadata

  • Download URL: fixfield-0.1.0.tar.gz
  • Upload date:
  • Size: 16.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.10.9 {"installer":{"name":"uv","version":"0.10.9","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for fixfield-0.1.0.tar.gz
Algorithm Hash digest
SHA256 f1bd3bbcf4f5cbc4d2dfe4def6f948579d221635a3a8c4857157a2403133d876
MD5 d2e9c251ffd2dba9befcccfd3c3978cf
BLAKE2b-256 a0524898359ace7c6e6ed1bbc9670f891696bb004528712014eab5396e65926c

See more details on using hashes here.

File details

Details for the file fixfield-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: fixfield-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 10.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.10.9 {"installer":{"name":"uv","version":"0.10.9","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for fixfield-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 16b6a3f0af4e8a0d46966de2a74dcb75430d544d8be5cafa24a2d7a04c4282ad
MD5 cd223827e8a9cc11bc3dd6743f49c222
BLAKE2b-256 e2337533f2af27812c646aa2134d0a794924c2c6b3a334f8c381466c5990ac2d

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page