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").
|
|
||
| `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 |
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.