Support numeric constraints for decimal.Decimal - #1006
Conversation
|
It looks like the Git history needs to be fixed on this one. |
8c80626 to
f406983
Compare
|
@ofek done — force-pushed a clean rewrite of the branch:
Build job is still red on the link checker but that's the unrelated #1008 fix; nothing in this branch touches it. |
Extends gt, ge, lt, le, and multiple_of Meta constraints to work with decimal.Decimal types, enabling exact arithmetic comparisons and sub-integer precision constraints without floating point issues. Constraint bounds passed as int or float are coerced to Decimal via their string representation to preserve precision. multiple_of uses Python's % operator for exact arithmetic. Closes #683
d2ce46f to
023b9e1
Compare
|
Rebased onto main now that #1004 has landed. #1004 took bits 36-37 for
No other constraint bits were moved. SLOT numbering and the |
provinzkraut
left a comment
There was a problem hiding this comment.
I think this should have a check that disallows Decimal for gt, ge, etc. constraints where the type is not also a Decimal
Coercing a Decimal bound to int or float silently drops precision, which defeats the point of specifying the bound as a Decimal in the first place. Only allow Decimal bounds when the annotated type is also Decimal, and raise a TypeError otherwise at TypeNode construction time.
|
@provinzkraut added the check: a Covered with a parametrized test, docs updated. |
| lt: Union[int, float, None] = None, | ||
| le: Union[int, float, None] = None, | ||
| multiple_of: Union[int, float, None] = None, | ||
| gt: Union[int, float, Decimal, None] = None, |
There was a problem hiding this comment.
This can now be a type alias, since it is repeated quite a lot and can be changed.
There was a problem hiding this comment.
Done, pulled it out into _NumericBound = int | float | Decimal | None.
| return false; | ||
| } | ||
| } | ||
| else if (PyObject_IsInstance(val, mod->DecimalType)) { |
There was a problem hiding this comment.
This allows Decimal subclasses, but you never test them. Since int and float subclasses are not allowed above - what is the correct way to handle this?
There was a problem hiding this comment.
int/float use *_CheckExact because bool is an int subclass (we don't want to silently accept True as a numeric bound). Decimal has no such footgun subclass, and _constr_as_decimal normalizes the bound through Decimal(obj) at TypeNode-build time anyway, so a subclass's overridden dunders never reach the checks (verified with a hostile subclass overriding __gt__/__mod__ to raise: they're never called). Accepting subclasses is intentional; added test_decimal_subclass_bound.
| if ((out = TypeNode_traverse(node, visit, arg)) != 0) return out; | ||
| } | ||
| /* Traverse Decimal constraint PyObject* */ | ||
| if (self->types & (MS_CONSTR_DECIMAL_GT | MS_CONSTR_DECIMAL_GE)) { |
There was a problem hiding this comment.
This should be done before return out. Otherwise it can be left in a broken state.
There was a problem hiding this comment.
Agreed, moved the Decimal-constraint Py_VISITs before the loops that can return out early. They're direct references on this node, so visiting them before recursing into children is the right place. (The Decimal pointers aren't counted in n_obj, so there's no double-visit.)
| PyObject *c = TypeNode_get_constr_decimal_min(type); | ||
| int ok = PyObject_RichCompareBool(obj, c, Py_GT); | ||
| if (ok == -1) return false; | ||
| if (MS_UNLIKELY(!ok)) { |
There was a problem hiding this comment.
I don't think that it is unlikely. I would say that ok == -1 is unlikely.
There was a problem hiding this comment.
The existing ms_decode_constr_int (and the float/str checks) mark the validation-failure path !ok as MS_UNLIKELY since valid data is the hot path, so I kept that for consistency. You're right about the error path though: marked ok < 0 as MS_UNLIKELY (and switched == -1 to < 0) in all four comparison blocks.
| PyObject *modulo = PyNumber_Remainder(obj, c); | ||
| if (modulo == NULL) return false; | ||
| PyObject *zero = PyLong_FromLong(0); | ||
| if (zero == NULL) { Py_DECREF(modulo); return false; } |
There was a problem hiding this comment.
| if (zero == NULL) { Py_DECREF(modulo); return false; } | |
| if (zero == NULL) { | |
| Py_DECREF(modulo); | |
| return false; | |
| } |
style nit.
There was a problem hiding this comment.
Dropped this block entirely, there's no zero to allocate anymore (see the multiple_of comment below).
| PyObject *c = TypeNode_get_constr_decimal_multiple_of(type); | ||
| PyObject *modulo = PyNumber_Remainder(obj, c); | ||
| if (modulo == NULL) return false; | ||
| PyObject *zero = PyLong_FromLong(0); |
There was a problem hiding this comment.
You don't need to do the second compare in Python, you can do this in C.
Convert modulo to PyNumber_AsSsize_t (or whatever) and compare it with == 0 :)
There was a problem hiding this comment.
PyNumber_AsSsize_t won't work here: Decimal has no __index__, so it'd raise TypeError on any remainder (including integral ones). Switched to PyObject_IsTrue(modulo) instead, a Decimal is falsy iff it's zero (including -0, 0E+10). That drops the zero allocation and stays correct for fractional remainders like Decimal("0.5").
There was a problem hiding this comment.
If we allow Decimal subclasses, we can't use PyObject_IsTrue, because __bool__ can be changed in subclasses :(
There was a problem hiding this comment.
Good point, switched to an explicit PyObject_RichCompareBool(modulo, zero, Py_EQ) against a cached small int - it expresses the intent directly and doesn't depend on __bool__ at all.
For the record, I checked reachability while fixing this: the scenario can't actually trigger today. Subclass values never reach the check (convert rejects them with Expected `decimal`, got `WeirdDecimal` , and decode paths construct plain Decimal), subclass bounds are normalized via Decimal(bound) at collection, and decimal arithmetic returns base-class instances anyway, so the remainder was always a plain Decimal. Still, the explicit comparison is clearly better here, and there's now a test pinning the behavior with a __bool__-overriding subclass bound (test_multiple_of_decimal_subclass_bound_bool_override).
|
|
||
| `decimal.Decimal` bounds (``gt``, ``ge``, ``lt``, ``le``, ``multiple_of``) | ||
| are only valid on `decimal.Decimal` types. Applying a `decimal.Decimal` | ||
| bound to an ``int`` or ``float`` type raises a `TypeError`, since coercing |
There was a problem hiding this comment.
Sorry, I have a question about int here. How does it lose precision?
There was a problem hiding this comment.
Good catch, "lose precision" was imprecise for int. It's not truncation: the bound would be converted via PyLong_AsLongLongAndOverflow, which needs __index__, and Decimal has none, so any Decimal (Decimal("2") as well as Decimal("1.5")) is rejected by the int conversion, and a fractional one has no integer equivalent at all. For float the bound would be rounded to the nearest binary float. Reworded the note to drop the incorrect "truncate".
# Conflicts: # src/msgspec/__init__.pyi # tests/unit/test_constraints.py
|
@provinzkraut this is already handled in this PR: a |
Frees up 4 bits in the types bitmask (main now uses bits 38-39 for frozendict, leaving too few free bits for per-constraint decimal bits). Also compare the multiple_of remainder against zero explicitly instead of relying on __bool__.
MS_TYPE_FROZENDICT took bits 38-39, MS_CONSTR_DECIMAL moved to bit 40.
|
Rebased on main and reworked the constraint storage, two commits:
Also addressed the Verified locally on 3.12 and 3.15.0a7 (WSL): full unit suite green on both (6351 / 6439 passed), decimal constraints + |
Merging this PR will degrade performance by 10.27%
Warning Please fix the performance issues or acknowledge them on CodSpeed. Performance Changes
Tip Investigate this regression by commenting Comparing Footnotes
|
# Conflicts: # src/msgspec/__init__.pyi
…string
The float branch already rejected inf and nan bounds; the Decimal branch
converted to double for the positivity check only and let Decimal('NaN')
and Decimal('Infinity') through. Also check PyObject_IsInstance for error.
|
Merged current main in, resolving the stub against the Holding this PR. Re-reviewing it turned up a real defect of mine: Fixed in this push: Decimal bounds now get the finiteness check the float bounds always had, and both that check and the Not fixed, because it is a semantics call that also decides how the remainder gets computed: for non-finite input, either follow float parity and let it fail the comparison as a This adds public API and touches TypeNodes, so it needs a real review rather than a merge on silence. |
`ensure_is_finite_numeric` converted int bounds with `PyLong_AsDouble`
and never checked the result. For an int outside the range of a double
the `OverflowError` was left pending and the function still returned
true, so `Meta(gt=10**400)` surfaced as `SystemError`, and for
`multiple_of` the `-1.0` sentinel tripped the `<= 0` check and reported
a large positive bound as non-positive. On this branch the misreport got
worse: `Meta(gt=10**400, lt=Decimal("5"))` blamed `decimal.Decimal`.
Rejecting those ints is not an option here, because `Meta` already
treats the two spellings as one value: `Meta(gt=10**400)` and
`Meta(gt=Decimal("1E+400"))` compare equal, hash equal, and collapse to
a single object inside `Annotated`. Accepting one and refusing the other
would also contradict this branch's own argument that a double is the
wrong gate for large-but-finite bounds.
So drop the double entirely for ints. Every int is finite, and the only
remaining check, positivity for `multiple_of`, is done exactly against
zero, which is what the Decimal branch already does; that block is
factored out into `ensure_is_positive` and shared.
Whether a bound is usable stays a per-type question, decided where the
annotated type is known, as it already was for `Meta(gt=2**64)`: int64
for `int` targets, float64 for `float` targets, exact for `Decimal`
targets. `_constr_as_f64` grows the missing half of that, turning the
overflow into the same kind of message `_constr_as_i64` already gives
instead of relying on an invariant that only held because the bug
crashed first.
Co-Authored-By: Claude Opus 5 <[email protected]>
|
Pushed two things: current Why it belongs here rather than in its own PR. The unchecked >>> msgspec.Meta(gt=10**400, lt=Decimal("5"))
TypeError: `lt` must be an int or float, got decimal.Decimaland here, before this push: >>> msgspec.Meta(gt=10**400, lt=Decimal("5"))
SystemError: <class 'decimal.Decimal'> returned a result with an exception setA typed error became a Accept rather than reject. The obvious fix is to raise a >>> Meta(gt=10**400) == Meta(gt=Decimal("1E+400"))
True
>>> hash(Meta(gt=10**400)) == hash(Meta(gt=Decimal("1E+400")))
True
So the int branch stops going through a double at all. Every int is finite, and the only remaining check, positivity for Usability stays a per-type question, as it already was for
Verified on the merge with current One cosmetic wart, disclosed rather than hidden: the int spelling produces Deliberately not in this push: @sobolevn this is still held on the semantics question from my last comment, and it has grown a third case. All three, on this branch: # 1. remainder against a bound that cannot divide
multiple_of=Decimal("3"), input "1E+30" -> InvalidOperation: [DivisionImpossible]
# 2. comparison against NaN
gt=Decimal("1"), input "NaN" -> InvalidOperation: [InvalidOperation]
# 3. Infinity satisfies any lower bound, silently
gt=Decimal("1"), input "Infinity" -> Decimal('Infinity')The third is the worst of them: it is a wrong answer rather than an exception, and the float path disagrees, rejecting My inclination is that all three should be @provinzkraut your check that a |
|
CI is green apart from CodSpeed, which is the known false flag, not something to look at. The single regressed benchmark is Everything else passed: tests on 3.10 through 3.15-dev, all seven wheel builds, Pyodide, integration, docs, memory benchmarks and the Deploy preview. |
) Calling `Meta(min_length=10**400)` results in a `ValueError` with the incorrect message "must be >= 0" for a positive integer. With this fix, out-of-range `min_length` and `max_length` values result in an error identifying the range issue. Accepted values and diagnostics for representable negative values are unchanged. This is the length-bound fix discussed in msgspec#1122, separate from the numeric constraints work in msgspec#1006. Co-authored-by: Александр Сергеевич Целуйко <[email protected]>
Reopening #998 (auto-closed when fork was deleted).
Fixes #683.
Previously, the numeric constraints
gt,ge,lt,le, andmultiple_ofinmsgspec.Metawere only valid forintandfloattypes. This PR extends support todecimal.Decimal.PR #692 by @cocorigon has been open since May 2024. The author has since explicitly declined to continue it and invited others to take it over. This is a fresh implementation with documentation and full test coverage.
Changes
_core.c: Added five newTypeNodeconstraint flags backed byPyObject*slots storingDecimalinstances. A newms_check_decimal_constraints()function is called after everyDecimaldecode path.__init__.pyi: UpdatedMeta.__init__parameter types to includeDecimal.docs/constraints.rst: Updated Numeric Constraints section to mentiondecimal.Decimal.tests/unit/test_constraints.py: AddedTestDecimalConstraintscovering all constraint types.Notable design decisions
multiple_offorDecimaluses Python's%operator, which gives exact arithmetic.floatare converted toDecimalvia string representation to preserve precision.