Add callable support for decimal_format option - #978
Conversation
2357ed5 to
076b58c
Compare
2ee9741 to
31effee
Compare
117001b to
b5ff6b7
Compare
|
CI failures are unrelated to this change:
All build, test, and wheel jobs pass across all platforms. |
b5ff6b7 to
09b8d01
Compare
|
Code looks solid and CI is green across the matrix - nice work, especially the test coverage in One API design question I'd like to raise before this moves forward: the current shape places An alternative would be to attach quantization to the type via Price = Annotated[Decimal, Meta(decimal_quantize="0.0001", decimal_rounding="ROUND_HALF_EVEN")]That composes naturally with per-field configuration, lives next to the type where the constraint is logically defined, and matches how Did you consider the cc @jcrist @ofek — this expands the encoder API surface, so I'd like your read on whether the encoder-kwarg shape is the one we want, or whether |
|
Instead of two new options for quantization, how about adding a single # Uses default rounding
enc = Encoder(decimal_format="0.0001")
# Custom rounding
enc = Encoder(decimal_format=lambda d: d.quanitize(decimal.Decimal("0.001"), "ROUND_DOWN"))I like this since it's more flexible, and also only adds a single new option. Otherwise I'd worry about other users needing further customization, resulting in a number of I wouldn't expect a callable here to have a perf cost - calling into python here is negligible, most of the time will be in the
In For now a single setting on an |
|
@Siyet @jcrist Hello! Thanks for the review!
I hadn't considered using
I like this idea, but the |
|
After thinking it through I'm coming around to @jcrist's single-kwarg shape. One slot for everything is, in my view, the right call here. Encoder(decimal_format=lambda d: d.quantize(Decimal("0.001"), ROUND_DOWN)) # custom
Encoder(decimal_format="string") # existing
Encoder(decimal_format="number") # existingWe could split it along There is also the naming angle: @NyanFisher regarding your concern about overloading an existing kwarg: the three shapes ( |
09b8d01 to
f1d9799
Compare
decimal_format option
9485ce8 to
4810273
Compare
|
Please review this PR when you have a moment 🙂 I changed the implementation. |
Siyet
left a comment
There was a problem hiding this comment.
Re-checked after the rework. CI is green across the matrix, design matches what we landed on. Ran some scenarios beyond the existing tests locally (WSL Ubuntu 22.04, Python 3.10, build at 4810273), three blockers inline.
Plus docs: docs/supported-types.rst:595-606 only mentions 'string'/'number', would be good to add a callable example covering the use case from #848.
Nit: test_encoder_decimal_callable_raise_error_if_fn_return_decimal should use match="must not return a Decimal" to pin the message rather than any TypeError.
e0fbd2a to
b11ce2b
Compare
|
@Siyet Hello! I have corrected the comments. Please re-check this PR when you have a moment. |
|
Hi @Siyet ! Just gently bumping this pull request. Let me know if there’s anything I can fix or rebase to help get it merged! 🙂 |
|
Looks good. A few non-blocking things before merge:
|
c70348b to
06a3a3f
Compare
06a3a3f to
468dea0
Compare
|
@Siyet, I have corrected the comments.
I was thinking, maybe we should keep if (decimal_format == NULL) {
...
}
else if (PyCallable_Check(decimal_format)) {
...
}
else {
bool ok = false;
if (PyUnicode_CheckExact(decimal_format)) {
if (PyUnicode_CompareWithASCIIString(decimal_format, "string") == 0) {
self->decimal_format = DECIMAL_FORMAT_STRING;
ok = true;
}
else if (PyUnicode_CompareWithASCIIString(decimal_format, "number") == 0) {
self->decimal_format = DECIMAL_FORMAT_NUMBER;
ok = true;
}
if (!ok) {
PyErr_Format(
PyExc_ValueError,
"`decimal_format` must be 'string', 'number', or a callable, got %R",
decimal_format
);
return -1;
}
}
}What do you think? |
|
@sobolevn, hello! Could you please join the review of this PR? I want to finish it. 🙂 |
Merging this PR will improve performance by 11.74%
Performance Changes
Tip Curious why this is faster? Comment Comparing Footnotes
|
sobolevn
left a comment
There was a problem hiding this comment.
Thanks! Looks really promising!
| PyErr_Format( | ||
| PyExc_ValueError, | ||
| "`decimal_format` must be 'string' or 'number', got %R", | ||
| "`decimal_format` must be 'string', 'number', or a callable, got %R", |
There was a problem hiding this comment.
Let's not repeat this error twice :)
There was a problem hiding this comment.
Removed the duplication.
I have changed the terms to maintain backward compatibility. I wrote about it in a comment.
There was a problem hiding this comment.
Yes, we should not change the error type here. Otherwise, we can break something for users.
| ): | ||
| enc.encode(decimal.Decimal("1")) | ||
|
|
||
| def test_encoder_decimal_callable_raise_error(self, proto): |
There was a problem hiding this comment.
Question (maybe for another PR): do we explicitly test _pydecimal types? where decimal.c was not available / compiled?
There was a problem hiding this comment.
Do you mean such tests?
@pytest.fixture
def force_pydecimal(monkeypatch):
monkeypatch.delitem(sys.modules, "_decimal", raising=False)
monkeypatch.delitem(sys.modules, "decimal", raising=False)
monkeypatch.setitem(sys.modules, "_decimal", None)
if "decimal" in sys.modules:
importlib.reload(sys.modules["decimal"])
yield
monkeypatch.delitem(sys.modules, "_decimal", raising=False)
if "decimal" in sys.modules:
importlib.reload(sys.modules["decimal"])
def test_pydecimal(force_pydecimal):
import decimalI have never written such tests. 😃
There was a problem hiding this comment.
We can do that in a separate PR.
- Add in_decimal_callable flag to EncoderState to track when we're inside a decimal_format callable - Raise TypeError if decimal_format callable returns a value containing a Decimal (would cause infinite recursion) - Add documentation with examples for decimal_format callable usage - Update stubs to allow Any return type from decimal_format callable
a2d7766 to
a3f03ce
Compare
sobolevn
left a comment
There was a problem hiding this comment.
Awesome! Just one comment from me :)
| PyErr_SetString( | ||
| PyExc_TypeError, | ||
| "callable returned a value containing a Decimal" | ||
| ); | ||
| return -1; |
There was a problem hiding this comment.
| PyErr_SetString( | |
| PyExc_TypeError, | |
| "callable returned a value containing a Decimal" | |
| ); | |
| return -1; | |
| return ms_decimal_format_error(); |
this can also be an inline function.
Siyet
left a comment
There was a problem hiding this comment.
Second review after @sobolevn's approval, and it's a solid one - LGTM.
What I checked on 8c52acf (deep pass, including an empirical run):
- The recursion guard survived the post-review refactors: a callable returning a
Decimal(direct or nested arbitrarily deep in dict/list/tuple, json and msgpack alike) raises the cleanTypeError, never aRecursionErroror crash. Exceptions from the callable propagate as-is,KeyboardInterruptincluded. - Refcounting and GC are clean:
Py_VISIT/Py_CLEARon the callable are wired into both encoder types, encoder-in-a-cycle-with-its-callable collects, and 600k encodes / 300k error-path iterations / 100k encoder create-destroy cycles all show zero RSS growth. - All four
EncoderStatecreation sites (encode, encode_into, encode_lines, module-level) initialize the new fields;string/numberoutput is byte-identical to 0.21.1 for both protocols. - Stubs (
_DecimalFormatSig) match runtime validation, getter returns the callable by identity,enc_hookis not consulted forDecimalin any mode. - All review threads are addressed in code; the one deferred item for the record is
_pydecimaltest coverage, agreed to land as a separate PR.
Two tiny wording nits, fine to fix in a follow-up push or leave as is:
- The msgpack
Encoderdocstring and thesupported-types.rstnote both say the callable "must return a JSON-serializable value" - for msgpack that reads oddly; "a value encodable by the encoder" would cover both. - The docs warning says returning a
Decimal"would cause infinite recursion" and then that aTypeErroris raised - the guard means there's no recursion at all, the second half is what actually happens.
Since this is a feature, the final call is @ofek's per our merge policy - from my side it's ready.
|
Correcting myself from the review above: I keep forgetting our maintainer pool has grown. With @sobolevn's approval and mine this PR has two maintainer reviews, and the API shape itself was originally suggested by @jcrist - that's plenty for a feature. No need to gate on one specific person, so merging with a clear conscience. Thanks @NyanFisher for seeing this through! |
Batches the changelog entries accumulated since the 0.21.1 release, following the usual pre-release batching practice (like msgspec#1020). Covers merged user-facing changes through msgspec#1109/msgspec#978/msgspec#1105; internal CI/benchmarking/test-infra changes are intentionally omitted. Also records the move to the msgspec GitHub organization. --------- Co-authored-by: Siyet <Siyet@users.noreply.github.com>
Hello!
Description of the problem solved by this PR
The msgspec library has many useful features, but the current version lacks the ability to correctly quantize Decimal values during encoding. In applications related to finance and precise calculations, it is critical to take into account maximum accuracy and return values rounded to a specified precision. Without implementing this functionality, a complete transition from pydantic to msgspec is not possible.
Changes implemented in this PR
Core Functionality
in_decimal_callablefield toEncoderStatestructmpack_encode_decimal()andjson_encode_decimal()Validation & Safety
Decimalreturns:lambda d: dlambda d: [d],lambda d: {"key": d}Type Hints
Examples
Rounding to 2 Decimal Places
MessagePack with Rounding
Error: Returning Decimal from Callable
I would appreciate any comments on improving or restructuring the code, as I don't often write in C.
Fix my issue - Closes #848