Skip to content

Add decimal module (#218)#538

Open
imran2140 wants to merge 1 commit into
pydantic:mainfrom
imran2140:decimal-module
Open

Add decimal module (#218)#538
imran2140 wants to merge 1 commit into
pydantic:mainfrom
imran2140:decimal-module

Conversation

@imran2140

@imran2140 imran2140 commented Jul 7, 2026

Copy link
Copy Markdown

Closes #218

Summary

Implements decimal.Decimal as a Rust port of CPython 3.14's _pydecimal.py numeric core, running under a fixed context matching CPython's defaults (prec=28, ROUND_HALF_EVEN, Emax=±999999). Arithmetic results are digit-for-digit identical to CPython's — the ported functions cite their _pydecimal line numbers so they can be diffed against the original.

Divergences from CPython (fixed context, reduced method surface, input caps, qualified __name__s) are documented in limitations/decimal.md.

Assisted-by: Claude:claude-5-fable

Implementation notes

  • Numeric core (crates/monty/src/types/decimal/): construction from str | int | float | Decimal and the as_tuple form; add/sub/mul/div, floordiv/mod/divmod, 2- and 3-arg pow, sqrt/ln/log10/exp, quantize/normalize to_integral_value, the is_* predicates, as_tuple/adjusted/copy_*, and the builtins int()/float()/round()/abs()/divmod()/pow()/hash() plus math.floor/ceil/trunc.
  • Sandbox guards: CPython's algorithms are bounded given bounded operands, so every constructor path caps coefficients/NaN payloads at 4300 digits (the intstr limit) and bounds literal exponents; attacker-scalable allocations (powers of ten, format zero-pads) are charged to the ResourceTracker, raising MemoryError where CPython would build a multi-GB value.
  • Dispatch: a single decimal::binary_op_value probe serves all seven binary operators and the pow() builtin — promotion runs before the coefficient is cloned, and the probe owns CPython's sequence-repeat special case ('a' * Decimal(2)).
  • Comparisons/hash: exact CPython semantics including NaN/sNaN (the VM's identity shortcut now exempts Decimal so nan != nan even for the same object); hash(Decimal(5)) == hash(5) cross-type consistency in-sandbox.
  • Exceptions: the full taxonomy is importable with CPython's multi-parent relationships; only the default-trapped signals (InvalidOperation, DivisionByZero, Overflow) ever raise.
  • Formatting: str/repr and the full __format__ spec mini-language, emitted through tracker-reserved StringBuilders.
  • VM/heap/snapshots: HeapData::Decimal + HeapReadOutput variants (appended last for snapshot index stability, like Type/MontyObject/MontyType); snapshot deserialization validates Decimal payloads and rejects corrupt bytes safely.
  • Host boundary: a Decimal crosses losslessly as its canonical string — MontyObject::Decimal through the wire protocol (proto tag appended), decimal.Decimal in pydantic_monty, a tagged string in @pydantic/monty; hand-authored typeshed stub advertises only the implemented surface.
  • Tests: seven test_cases/decimal__*.py files assert exact values and exception messages under both CPython and Monty; decimal_guards.rs covers the resource-limit paths; binary/JSON serde, host-input, and Python/JS binding tests cover the boundary.

Summary by cubic

Adds a decimal module implementing decimal.Decimal under a fixed context matching CPython defaults (prec=28, ROUND_HALF_EVEN, Emax=±999999). Results, formatting, comparisons, and exceptions match CPython; values cross the host boundary losslessly as canonical strings.

  • New Features

    • decimal.Decimal port with constructors (str/int/float/Decimal/as_tuple), arithmetic (+ - * / // % divmod pow incl. 3-arg), transcendentals (sqrt ln log10 exp), methods (quantize normalize to_integral_value copy_sign as_tuple), predicates, and __format__; builtins int/float/round/abs/divmod/pow/hash and math.floor/ceil/trunc wired up.
    • Exact CPython comparison, NaN/sNaN behavior, and cross-type hash (hash(Decimal(5)) == hash(5)); VM equality operator updated to honor Decimal semantics.
    • Full decimal exception hierarchy (decimal.DecimalException, InvalidOperation, DivisionByZero, Overflow, etc.); only the default-trapped signals raise.
    • Host boundary integration:
      • New MontyObject::Decimal carried as the canonical string (e.g. "1.20", "1E+5", "NaN"); Protobuf adds field 29; JSON uses externally tagged form {"Decimal":"<str>"}.
      • JS: { __monty_type__: 'Decimal', value: '<str>' } round-trips; Python: Decimal converts via str(); bindings updated in pydantic_monty and @pydantic/monty.
      • Snapshots/serde support Decimal and validate payloads on load.
    • Sandbox guards: coefficient/NaN payload capped at 4300 digits; literal exponent bounds enforced; large 10**k and format zero-pads are charged to the resource tracker; oversized boundary inputs are rejected.
    • Typeshed: added decimal.pyi subset (fixed-context surface) and enabled in version tables; type-checking tests cover common operations.
    • Stdlib: decimal is now importable; README updated.
  • Migration

    • If you consume Monty objects over RPC/JSON, add handling for MontyObject::Decimal (Protobuf tag 29; JSON {"Decimal":"<str>"}) and, in JS, { __monty_type__: 'Decimal', value: '<str>' }.
    • Exception mapping: hosts should recognize decimal.* exception names so except decimal.* in-sandbox catches mapped host errors.
    • No snapshot incompatibility: new enum/variant entries are appended to preserve existing indices.

Written for commit 944dd52. Summary will update on new commits.

Review in cubic

Port of CPython 3.14's `_pydecimal`-core with a fixed context

Assisted-by: Claude:claude-5-fable
@codspeed-hq

codspeed-hq Bot commented Jul 7, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 26 untouched benchmarks
⏩ 16 skipped benchmarks1


Comparing imran2140:decimal-module (944dd52) with main (4bcbcf5)

Open in CodSpeed

Footnotes

  1. 16 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 944dd528e2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +558 to +560
ExcType::DecimalException => decimal_exc(py, "DecimalException", msg),
ExcType::DecimalInvalidOperation => decimal_exc(py, "InvalidOperation", msg),
ExcType::DecimalDivisionByZero => decimal_exc(py, "DivisionByZero", msg),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve decimal exception types from Python callbacks

This adds Monty→Python reconstruction for decimal.* exceptions, but the reverse path still goes through py_err_to_exc_type, whose ArithmeticError branch only recognizes built-in ZeroDivisionError/OverflowError. When a Python external callback raises decimal.InvalidOperation (or decimal.DivisionByZero, etc.), it is converted back into ArithmeticError/ZeroDivisionError, so sandbox code with except decimal.InvalidOperation: will not catch it even though the decimal exception classes are now exposed. Add the symmetric decimal isinstance checks before the built-in arithmetic fallback.

Useful? React with 👍 / 👎.

field_names,
vec![sign, digits_tuple, exponent],
);
Ok(Value::Ref(vm.heap.allocate(HeapData::NamedTuple(named))?))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Drop allocated tuple parts if final as_tuple allocation fails

If Decimal.as_tuple() runs under a memory limit and the final NamedTuple heap allocation fails, the already-created digits_tuple (and the special-value exponent string just above) are dropped without drop_with_heap because they are inside the NamedTuple passed to heap.allocate. That leaks refcounts in production and can panic under memory-model-checks; guard or explicitly clean up these Value::Refs on the allocation error path.

Useful? React with 👍 / 👎.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 68 files

Re-trigger cubic

@imran2140 imran2140 changed the title Add decimal module (#218) Add decimal module (#218) Jul 7, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this file is almost certainly not vendored, looks suspicious?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, it's duplicate of crates/monty-typeshed/custom/decimal.pyi, the duplication seems intended according to the crate's README. (copied by monty-typeshed/update.py#L332-L337, introduced in #48)

Comment thread crates/monty/src/value.rs
Comment on lines +1733 to +1754
/// Python's `==` *operator* (CPython's `do_richcompare`), which — unlike
/// [`Self::py_eq`] — has no same-object shortcut: identity is only the
/// default `object.__eq__`, consulted after the type's own equality.
///
/// The distinction matters for the first type with non-reflexive equality:
/// `x = Decimal('NaN'); x == x` must be `False` (and `Decimal('sNaN')`
/// must raise `InvalidOperation`) even though `{x: 1}[x]` still succeeds
/// via the container shortcut. For every type whose `py_eq_impl` returns
/// `NotImplemented` on itself (class instances, files, …), the identity
/// fallback keeps `obj == obj` true.
pub fn py_eq_operator(&self, other: &Self, vm: &mut VM<'_, impl ResourceTracker>) -> RunResult<bool> {
if let (Self::Ref(id), Self::Ref(other_id)) = (self, other)
&& id == other_id
{
match vm.heap.read(*id).py_eq_impl(other, vm)? {
Some(result) => Ok(result),
None => Ok(true), // default object identity
}
} else {
self.py_eq(other, vm)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please revert this split; instead we should fix py_eq_impl to not have the short-circuit.

Comment thread crates/monty/src/value.rs

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The changes in this file are pretty gross; we should probably clean up the trait so that these changes aren't necessary and they are more localised to the decimal implementation.

Comment thread crates/monty/src/run.rs
/// Returns an error if deserialization fails.
pub fn load(bytes: &[u8]) -> Result<Self, postcard::Error> {
postcard::from_bytes(bytes)
from_snapshot_bytes(bytes)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This general set of changes seems suspect, should either be split out and justified separately or reverted.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems problematic; why not use a rust implementation such as rust-decimal?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I initially tried to base the implementation on fastnum, which seemed like a good fit given it tries to follow IBM’s General Decimal Arithmetic Specification, like python stdlib decimal. But Claude kept layering on workarounds for various operations because of subtle differences in the observed behavior in tests (e.g. Decimal('-0') // Decimal('1') produces Decimal('-0') in Python preserving the sign, which wasn't the case with the out of the box implementation in fastnum). I also had implementation of decimal Context in-scope in that fastnum based port, which compounded the complexity. When I decided to re-implement again, I thought letting Claude port limited part of _pydecimal may not be a bad idea, and it also avoided the need to bring in another dependency.

Let me know if you'd prefer me to open an alternative PR with the fastnum based implementation, or iterate on this PR to make it merge-able.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

decimal module support in scope?

2 participants