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.2.0.tar.gz (24.4 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.2.0-py3-none-any.whl (14.2 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: fixfield-0.2.0.tar.gz
  • Upload date:
  • Size: 24.4 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.2.0.tar.gz
Algorithm Hash digest
SHA256 6cc4c9112b6954c0d349b7d693dc4430588843d7015e7a7491518d20c84b8ce2
MD5 647d5557e1e657f9c5508a8752cd180e
BLAKE2b-256 5b40434762c9e0ede2d0b9b675046f52e9bf438023ef2a436ae3c3a2b9f380ee

See more details on using hashes here.

File details

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

File metadata

  • Download URL: fixfield-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 14.2 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.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 9981c2350cb97eb5b48bdd8af8cdfdf51d18d59518f3b4d3c6501b927fe34002
MD5 dee0a893dca3fc74602c605ef31631ad
BLAKE2b-256 dab145f651672166256d985d1184d489d64329c40c081b2c9814f7dfd6afaa61

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