Source code for fsh_lib.numeric

"""A :class:`~decimal.Decimal` that crosses the wire as a string.

A JSON *number* is an IEEE double, so serializing a high-precision
decimal as a number silently rounds anything past ~15 significant
digits.  To stay exact end-to-end, the value travels as a JSON
**string** (``"1.50"``) -- the precision-safe convention JavaScript
clients use, since they can't hold the value in a native ``number``
without the same rounding.

:data:`DecimalString` keeps the exact :class:`~decimal.Decimal` on
the Python side (validation and arithmetic stay exact) and emits a
string on the wire.  Its only job over Pydantic's default ``Decimal``
is to pin the OpenAPI schema to a clean ``{"type": "string"}`` on
*both* the request and response sides -- the raw default types a
request field as ``number | string``, which makes the generated
TypeScript awkward.  With the override the FE field is a plain
``string`` it parses with ``Number(...)`` or a decimal library
(``decimal.js`` / ``big.js``) when it needs to compute::

    from decimal import Decimal

    from fsh_lib.numeric import DecimalString
    from pydantic import BaseModel

    class LineItem(BaseModel):
        unit_price: DecimalString       # Decimal in, "9.99" out

    LineItem(unit_price="9.99").unit_price               # Decimal('9.99')
    LineItem(unit_price="9.99").model_dump(mode="json")  # -> "9.99"

Use :func:`decimal_string` to additionally carry a precision / scale
bound (``NUMERIC(precision, scale)`` in the database)::

    class LineItem(BaseModel):
        unit_price: decimal_string(max_digits=10, decimal_places=2)

On the FE the value is a (numeric) ``string``, not a ``typeof
"number"`` -- converting it to a native ``number`` reintroduces the
double-rounding the string form exists to avoid, so keep it as a
string and compute with a decimal library when precision matters.
"""

from __future__ import annotations

from decimal import Decimal
from typing import Annotated, Any

from pydantic import Field, WithJsonSchema

#: Pin the schema to a plain string in both the validation
#: (request) and serialization (response) schemas.  Pydantic already
#: *serializes* a ``Decimal`` to a string; this only replaces the
#: noisy auto-derived schema (``number | string`` on requests, a
#: regex-``pattern`` string on responses) with a clean ``string`` so
#: ``openapi-ts`` types the FE field as ``string`` on both sides.
_STRING_SCHEMA = WithJsonSchema({"type": "string"})

DecimalString = Annotated[Decimal, _STRING_SCHEMA]
"""A :class:`~decimal.Decimal` that serializes to a JSON string.

Exact on the Python side; a precision-safe string on the wire;
``string`` in the generated TypeScript.  See the module docstring
for why a string (rather than a JSON number) is the lossless choice.
"""


[docs] def decimal_string( *, max_digits: int | None = None, decimal_places: int | None = None, ) -> Any: """Build a :data:`DecimalString` carrying a precision / scale bound. The returned annotation behaves exactly like :data:`DecimalString` (Decimal in Python, string on the wire and in TypeScript) but additionally validates the value against the supplied constraints -- mirroring a ``NUMERIC(max_digits, decimal_places)`` column so a request that overflows the column is rejected at the edge rather than by the database. Args: max_digits: Maximum total number of significant digits (the SQL *precision*). ``None`` leaves it unbounded. decimal_places: Maximum number of digits after the decimal point (the SQL *scale*). ``None`` leaves it unbounded. Returns: A typing annotation -- typed ``Any`` so it can sit directly in an ``x: decimal_string(...)`` field annotation -- equivalent to ``Annotated[Decimal, Field(max_digits=..., decimal_places=...), ...]`` with the string schema applied. """ return Annotated[ Decimal, Field(max_digits=max_digits, decimal_places=decimal_places), _STRING_SCHEMA, ]
__all__ = ["DecimalString", "decimal_string"]