Conversation
`decimal=True` exists to make money arithmetic exact, but the rounding mode was not reachable: `_convert_to_decimal` quantizes without passing `rounding`, so the decimal context decides — `ROUND_HALF_EVEN` out of the box. An invoice of 2.50 at 5% VAT yields 0.125, which becomes 0.12 instead of the 0.13 that commercial rounding (and the invoice in the accounting system next door) produces. There was no way to ask for a different mode short of subclassing the bone or mutating the decimal context process-wide. `NumericBone(precision=2, decimal=True, rounding=decimal.ROUND_HALF_UP)` now does it. Backwards compatible by construction: the default is `None`, and `None` is the decimal module's own "use the context" — it is passed through to `quantize()` unchanged, so every existing bone keeps the behaviour it has, including one that relies on an application-set context. Two guards, both loud at definition time rather than at the first write: - The value is checked against `ROUNDING_MODES` in `__setattr__`, next to the existing min/max guard, so a typo also fires on later assignment. - `rounding` without `decimal=True` raises instead of being ignored. The float mode rounds through `round(float(value), precision)`, which is half-to-even *and* subject to binary representation (`round(2.675, 2) == 2.67`); a knob there would promise an exactness that mode cannot deliver. Silently dropping the argument would be the worse answer. Tests: 8 new cases in TestNumericBone_Rounding — the default still following the context, HALF_UP across all four accepted input types, the mode reaching `singleValueFromClient` and `singleValueSerialize`, CEILING/FLOOR, and the three rejection paths. Suite goes 681 -> 689, all green. The ADR for the bone is updated accordingly.
skoegl
requested review from
ArneGudermann,
phorward,
sveneberth and
theVAX
September 18, 2026 15:50
phorward
requested changes
Sep 22, 2026
phorward
left a comment
Member
There was a problem hiding this comment.
Please rename the parameter and enum to just round.
| Which are around 2 ** (8 * 8 - 1) negative and 2 ** (8 * 8 - 1) positive values. | ||
| """ | ||
|
|
||
| ROUNDING_MODES = frozenset({ |
| max: int | float = MAX, | ||
| precision: int = 0, | ||
| decimal: bool = False, | ||
| rounding: str | None = None, |
rounding to NumericBone for commercial roundinground-parameter to NumericBone
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
decimal=Trueexists to make money arithmetic exact — but the rounding mode is not reachable._convert_to_decimalcallsquantize(self._quantize_exp)without aroundingargument, so the active decimal context decides, and that isROUND_HALF_EVENout of the box.For invoicing that is the wrong default and there is no way to change it short of subclassing the bone or mutating the decimal context process-wide. A concrete case: a line of 2.50 at 5 % VAT produces 2.50 × 5 / 100 = 0.125.
NumericBone(precision=2, decimal=True)todayROUND_HALF_UP)Half-to-even is a perfectly good default for statistics. It is the wrong one when the number has to match an invoice that a bookkeeping system produced with commercial rounding — there the cent is simply wrong, and it is wrong in a way that only shows up on half-cent boundaries, i.e. rarely and unpredictably.
What
roundingaccepts any of the decimal module's modes, exposed asROUNDING_MODES.Backwards compatible by construction. The default is
None, andNoneis the decimal module's own "use the context" — it is passed straight through toquantize(). Every existing bone keeps exactly the behaviour it has, including one in an application that sets the context itself:How
Four
quantize()calls gainrounding=self.rounding— the three branches of_convert_to_decimalandgetEmptyValue. No branching, becauseNonealready means "context".Two guards, both firing at definition time rather than at the first write:
ROUNDING_MODESin__setattr__, next to the existing min/max guard, so a typo also fires on later assignment.roundingwithoutdecimal=Trueraises instead of being quietly ignored. The float mode rounds throughround(float(value), precision), which is half-to-even and subject to binary representation —round(2.675, 2)is2.67regardless of any mode. A knob there would promise an exactness that mode cannot deliver, and silently dropping the argument would be the worse answer. I'd rather draw the line explicitly than half-support it.Tests
8 cases in
TestNumericBone_Rounding:ROUND_HALF_UPacross all four accepted input types (Decimal,str, comma-str,float)singleValueFromClientandsingleValueSerialize, not just the private helperROUND_CEILING/ROUND_FLOORwork tooroundingwithoutdecimalpython tests/main.pygoes 681 → 689, all green.docs/adr/bones/numeric.mdis updated: the new rule under Rules, and the float-moderound()behaviour added under Traps, since it is the reason for the scope line above.