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"]