From 944dd528e277931bc243a0fb9259d41dcc33a024 Mon Sep 17 00:00:00 2001 From: imran2140 Date: Mon, 6 Jul 2026 02:15:33 +1000 Subject: [PATCH] Add `decimal` module (#218) Port of CPython 3.14's `_pydecimal`-core with a fixed context Assisted-by: Claude:claude-5-fable --- README.md | 2 +- crates/monty-js/__test__/types.spec.ts | 39 + crates/monty-js/src/convert.rs | 17 + crates/monty-js/ts/errors.ts | 18 + crates/monty-proto/proto/monty/v1/monty.proto | 4 + crates/monty-proto/src/wire.rs | 6 + crates/monty-proto/tests/differential.rs | 5 + crates/monty-proto/tests/oracle/monty.v1.rs | 7 +- crates/monty-proto/tests/roundtrip.rs | 6 + crates/monty-python/src/convert.rs | 13 + crates/monty-python/src/exceptions.rs | 32 + crates/monty-python/tests/test_decimal.py | 58 ++ .../monty-type-checking/tests/good_types.py | 55 ++ crates/monty-typeshed/custom/decimal.pyi | 108 +++ crates/monty-typeshed/update.py | 1 + .../vendor/typeshed/stdlib/VERSIONS | 1 + .../vendor/typeshed/stdlib/decimal.pyi | 108 +++ crates/monty/src/builtins/abs.rs | 3 +- crates/monty/src/builtins/divmod.rs | 32 +- crates/monty/src/builtins/pow.rs | 28 +- crates/monty/src/builtins/round.rs | 20 + crates/monty/src/bytecode/vm/binary.rs | 4 +- crates/monty/src/bytecode/vm/compare.rs | 4 +- crates/monty/src/bytecode/vm/mod.rs | 81 +- crates/monty/src/exception_private.rs | 310 ++++++- crates/monty/src/fstring.rs | 36 +- crates/monty/src/heap.rs | 4 +- crates/monty/src/heap_data.rs | 43 +- crates/monty/src/intern.rs | 109 +++ crates/monty/src/modules/decimal.rs | 69 ++ crates/monty/src/modules/math.rs | 21 +- crates/monty/src/modules/mod.rs | 5 + crates/monty/src/object.rs | 27 +- crates/monty/src/repl.rs | 24 +- crates/monty/src/run.rs | 4 +- crates/monty/src/run_progress.rs | 3 +- crates/monty/src/types/decimal/arith.rs | 635 +++++++++++++ crates/monty/src/types/decimal/cmp.rs | 390 ++++++++ crates/monty/src/types/decimal/fix.rs | 284 ++++++ crates/monty/src/types/decimal/format.rs | 422 +++++++++ crates/monty/src/types/decimal/methods.rs | 435 +++++++++ crates/monty/src/types/decimal/mod.rs | 577 ++++++++++++ crates/monty/src/types/decimal/parse.rs | 393 +++++++++ crates/monty/src/types/decimal/pow.rs | 654 ++++++++++++++ crates/monty/src/types/decimal/trans.rs | 834 ++++++++++++++++++ crates/monty/src/types/list.rs | 2 +- crates/monty/src/types/long_int.rs | 14 +- crates/monty/src/types/mod.rs | 1 + crates/monty/src/types/py_trait.rs | 10 +- crates/monty/src/types/str.rs | 2 +- crates/monty/src/types/tuple.rs | 2 +- crates/monty/src/types/type.rs | 10 +- crates/monty/src/value.rs | 188 +++- crates/monty/test_cases/class__type_errors.py | 15 + .../monty/test_cases/decimal__arithmetic.py | 209 +++++ crates/monty/test_cases/decimal__basics.py | 138 +++ crates/monty/test_cases/decimal__cmp_hash.py | 185 ++++ crates/monty/test_cases/decimal__errors.py | 168 ++++ crates/monty/test_cases/decimal__format.py | 139 +++ crates/monty/test_cases/decimal__math.py | 31 + crates/monty/test_cases/decimal__methods.py | 260 ++++++ crates/monty/tests/binary_serde.rs | 96 ++ crates/monty/tests/decimal_guards.rs | 122 +++ crates/monty/tests/inputs.rs | 61 ++ crates/monty/tests/json_serde.rs | 18 + limitations/decimal.md | 147 +++ limitations/modules.md | 3 +- pyproject.toml | 6 +- 68 files changed, 7650 insertions(+), 108 deletions(-) create mode 100644 crates/monty-python/tests/test_decimal.py create mode 100644 crates/monty-typeshed/custom/decimal.pyi create mode 100644 crates/monty-typeshed/vendor/typeshed/stdlib/decimal.pyi create mode 100644 crates/monty/src/modules/decimal.rs create mode 100644 crates/monty/src/types/decimal/arith.rs create mode 100644 crates/monty/src/types/decimal/cmp.rs create mode 100644 crates/monty/src/types/decimal/fix.rs create mode 100644 crates/monty/src/types/decimal/format.rs create mode 100644 crates/monty/src/types/decimal/methods.rs create mode 100644 crates/monty/src/types/decimal/mod.rs create mode 100644 crates/monty/src/types/decimal/parse.rs create mode 100644 crates/monty/src/types/decimal/pow.rs create mode 100644 crates/monty/src/types/decimal/trans.rs create mode 100644 crates/monty/test_cases/decimal__arithmetic.py create mode 100644 crates/monty/test_cases/decimal__basics.py create mode 100644 crates/monty/test_cases/decimal__cmp_hash.py create mode 100644 crates/monty/test_cases/decimal__errors.py create mode 100644 crates/monty/test_cases/decimal__format.py create mode 100644 crates/monty/test_cases/decimal__math.py create mode 100644 crates/monty/test_cases/decimal__methods.py create mode 100644 crates/monty/tests/decimal_guards.rs create mode 100644 limitations/decimal.md diff --git a/README.md b/README.md index 16d790ad4..726b52426 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ What Monty **can** do: - Control resource usage - Monty can track memory usage, allocations, stack depth, and execution time and cancel execution if it exceeds preset limits - Collect stdout and stderr and return it to the caller - Run async or sync code on the host via async or sync code on the host -- Use a small subset of the standard library: `sys`, `os`, `typing`, `asyncio`, `re`, `datetime`, `json`, `dataclasses` (soon) +- Use a small subset of the standard library: `sys`, `os`, `typing`, `asyncio`, `re`, `datetime`, `decimal`, `json`, `dataclasses` (soon) What Monty **cannot** do: diff --git a/crates/monty-js/__test__/types.spec.ts b/crates/monty-js/__test__/types.spec.ts index 315dea4cd..251bd4a58 100644 --- a/crates/monty-js/__test__/types.spec.ts +++ b/crates/monty-js/__test__/types.spec.ts @@ -190,6 +190,45 @@ test('ellipsis output', async (t) => { t.deepEqual(await run('...'), { __monty_type__: 'Ellipsis' }) }) +// ============================================================================= +// Decimal tests (JS has no decimal type — carried as a tagged string) +// ============================================================================= + +test('decimal output', async (t) => { + t.deepEqual(await run("from decimal import Decimal\nDecimal('1.23') + Decimal('4.56')"), { + __monty_type__: 'Decimal', + value: '5.79', + }) +}) + +test('decimal division rounds to prec', async (t) => { + t.deepEqual(await run('from decimal import Decimal\nDecimal(1) / Decimal(3)'), { + __monty_type__: 'Decimal', + value: '0.3333333333333333333333333333', + }) +}) + +test('decimal input roundtrip', async (t) => { + t.deepEqual(await run('x', { inputs: { x: { __monty_type__: 'Decimal', value: '1.20' } } }), { + __monty_type__: 'Decimal', + value: '1.20', + }) +}) + +test('decimal input arithmetic', async (t) => { + t.deepEqual(await run('x * 2', { inputs: { x: { __monty_type__: 'Decimal', value: '1.5' } } }), { + __monty_type__: 'Decimal', + value: '3.0', + }) +}) + +test('decimal snan payload roundtrip', async (t) => { + t.deepEqual(await run('x', { inputs: { x: { __monty_type__: 'Decimal', value: '-sNaN123' } } }), { + __monty_type__: 'Decimal', + value: '-sNaN123', + }) +}) + // ============================================================================= // Nested collection tests // ============================================================================= diff --git a/crates/monty-js/src/convert.rs b/crates/monty-js/src/convert.rs index 3071afb0d..ee85bdac7 100644 --- a/crates/monty-js/src/convert.rs +++ b/crates/monty-js/src/convert.rs @@ -78,6 +78,7 @@ pub fn monty_to_js<'e>(obj: &MontyObject, env: &'e Env) -> Result create_js_datetime(datetime, env)?, MontyObject::TimeDelta(delta) => create_js_timedelta(delta, env)?, MontyObject::TimeZone(timezone) => create_js_timezone(timezone, env)?, + MontyObject::Decimal(s) => create_js_decimal_marker(s, env)?, MontyObject::Type(t) => create_js_type_marker(&t.to_string(), env)?, MontyObject::BuiltinFunction(f) => create_js_builtin_function_marker(&f.to_string(), env)?, MontyObject::Dataclass { @@ -304,6 +305,16 @@ fn create_js_datetime<'e>(datetime: &MontyDateTime, env: &'e Env) -> Result(decimal_str: &str, env: &'e Env) -> Result> { + let mut obj = Object::new(env)?; + obj.set_named_property("__monty_type__", "Decimal")?; + obj.set_named_property("value", decimal_str)?; + obj.into_unknown(env) +} + /// Creates a JS object representing a Type: `{ __monty_type__: 'Type', value: '...' }`. fn create_js_type_marker<'e>(type_str: &str, env: &'e Env) -> Result> { let mut obj = Object::new(env)?; @@ -624,6 +635,12 @@ fn js_marked_object_to_monty(obj: &Object, monty_type: &str, env: Env) -> Result offset_seconds: obj.get_named_property::("offsetSeconds")?, name: obj.get_named_property::>("name")?, })), + "Decimal" => { + // Round-trips losslessly: the canonical string parses back to the + // same Decimal in the sandbox. + let value: String = obj.get_named_property("value")?; + Ok(MontyObject::Decimal(value)) + } "Type" => { // Type objects can't be fully round-tripped; return as Repr let value: String = obj.get_named_property("value")?; diff --git a/crates/monty-js/ts/errors.ts b/crates/monty-js/ts/errors.ts index 625ca1481..9d74dfc2b 100644 --- a/crates/monty-js/ts/errors.ts +++ b/crates/monty-js/ts/errors.ts @@ -229,6 +229,24 @@ export const PYTHON_EXC_NAMES: ReadonlySet = new Set([ 'TimeoutError', 'TypeError', 're.PatternError', + // decimal module exception taxonomy — every `decimal.*` class monty's + // `ExcType` can parse. Without these, a host error named e.g. + // `decimal.Underflow` would fall back to `RuntimeError` and `except + // decimal.Underflow:` in the sandbox would not catch it. + 'decimal.DecimalException', + 'decimal.InvalidOperation', + 'decimal.DivisionByZero', + 'decimal.Overflow', + 'decimal.Inexact', + 'decimal.Rounded', + 'decimal.Subnormal', + 'decimal.Clamped', + 'decimal.Underflow', + 'decimal.FloatOperation', + 'decimal.ConversionSyntax', + 'decimal.DivisionImpossible', + 'decimal.DivisionUndefined', + 'decimal.InvalidContext', ]) /** diff --git a/crates/monty-proto/proto/monty/v1/monty.proto b/crates/monty-proto/proto/monty/v1/monty.proto index 4711003d5..0e14fd5e7 100644 --- a/crates/monty-proto/proto/monty/v1/monty.proto +++ b/crates/monty-proto/proto/monty/v1/monty.proto @@ -80,6 +80,10 @@ message MontyObject { // decoder but rejected as an execution input (the class binding cannot be // reconstructed from a name). string instance_type = 28; + // A decimal.Decimal value, carried losslessly as its canonical string + // (e.g. "1.20", "1E+5", "NaN") — the same string Python's `str(Decimal)` + // produces, so it round-trips exactly through `Decimal(str)`. + string decimal = 29; } } diff --git a/crates/monty-proto/src/wire.rs b/crates/monty-proto/src/wire.rs index 86a8667e0..048e1531f 100644 --- a/crates/monty-proto/src/wire.rs +++ b/crates/monty-proto/src/wire.rs @@ -275,6 +275,7 @@ mod tag { pub const REPR: u32 = 26; pub const CYCLE: u32 = 27; pub const INSTANCE_TYPE: u32 = 28; + pub const DECIMAL: u32 = 29; } // ============================================================================ @@ -391,6 +392,7 @@ fn encode_object(obj: &MontyObject, buf: &mut impl BufMut) { encode_opt_str(2, docstring.as_deref(), buf); } MontyObject::Repr(r) => encoding::string::encode(tag::REPR, r, buf), + MontyObject::Decimal(s) => encoding::string::encode(tag::DECIMAL, s, buf), MontyObject::Cycle(identity, placeholder) => { let identity = *identity as u64; encode_message_key(tag::CYCLE, uint64_len(1, identity) + str_len(2, placeholder), buf); @@ -451,6 +453,7 @@ fn object_len(obj: &MontyObject) -> usize { submessage_len(tag::FUNCTION, str_len(1, name) + opt_str_len(2, docstring.as_deref())) } MontyObject::Repr(r) => encoding::string::encoded_len(tag::REPR, r), + MontyObject::Decimal(s) => encoding::string::encoded_len(tag::DECIMAL, s), MontyObject::Cycle(identity, placeholder) => { submessage_len(tag::CYCLE, uint64_len(1, *identity as u64) + str_len(2, placeholder)) } @@ -798,6 +801,9 @@ fn decode_field( } } tag::REPR => MontyObject::Repr(merge_string(wire_type, buf, ctx)?), + // The string's *decimal* validity is checked when it crosses into the + // sandbox (`MontyObject::to_value`); here we only need valid UTF-8. + tag::DECIMAL => MontyObject::Decimal(merge_string(wire_type, buf, ctx)?), tag::CYCLE => { let c: pb::Cycle = merge_message(wire_type, buf, ctx)?; let identity = usize::try_from(c.identity).map_err(|_| { diff --git a/crates/monty-proto/tests/differential.rs b/crates/monty-proto/tests/differential.rs index 957536a20..426fb1be9 100644 --- a/crates/monty-proto/tests/differential.rs +++ b/crates/monty-proto/tests/differential.rs @@ -53,6 +53,10 @@ fn corpus() -> Vec { MontyObject::String("héllo \u{1F40D}".to_owned()), MontyObject::Bytes(vec![]), MontyObject::Bytes(vec![0, 255, 128]), + MontyObject::Decimal("1.20".to_owned()), + MontyObject::Decimal("1E+5".to_owned()), + MontyObject::Decimal("NaN".to_owned()), + MontyObject::Decimal(String::new()), // default payload still encodes the arm MontyObject::List(vec![]), MontyObject::List(vec![ MontyObject::Int(1), @@ -268,6 +272,7 @@ fn to_oracle(obj: &MontyObject) -> oracle::MontyObject { docstring: docstring.clone(), }), MontyObject::Repr(r) => Kind::Repr(r.clone()), + MontyObject::Decimal(s) => Kind::Decimal(s.clone()), MontyObject::Cycle(identity, placeholder) => Kind::Cycle(oracle::Cycle { identity: *identity as u64, placeholder: placeholder.clone(), diff --git a/crates/monty-proto/tests/oracle/monty.v1.rs b/crates/monty-proto/tests/oracle/monty.v1.rs index 8ef314206..11728e1b1 100644 --- a/crates/monty-proto/tests/oracle/monty.v1.rs +++ b/crates/monty-proto/tests/oracle/monty.v1.rs @@ -22,7 +22,7 @@ pub struct Unit {} pub struct MontyObject { #[prost( oneof = "monty_object::Kind", - tags = "1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28" + tags = "1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29" )] pub kind: ::core::option::Option, } @@ -101,6 +101,11 @@ pub mod monty_object { /// reconstructed from a name). #[prost(string, tag = "28")] InstanceType(::prost::alloc::string::String), + /// A decimal.Decimal value, carried losslessly as its canonical string + /// (e.g. "1.20", "1E+5", "NaN") — the same string Python's `str(Decimal)` + /// produces, so it round-trips exactly through `Decimal(str)`. + #[prost(string, tag = "29")] + Decimal(::prost::alloc::string::String), } } #[derive(Clone, PartialEq, ::prost::Message)] diff --git a/crates/monty-proto/tests/roundtrip.rs b/crates/monty-proto/tests/roundtrip.rs index 27f01cb73..574397a23 100644 --- a/crates/monty-proto/tests/roundtrip.rs +++ b/crates/monty-proto/tests/roundtrip.rs @@ -35,6 +35,12 @@ fn scalar_values_round_trip() { assert_value_round_trip(&MontyObject::Bytes(vec![])); assert_value_round_trip(&MontyObject::Bytes(vec![0, 255, 128])); assert_value_round_trip(&MontyObject::Path("/mnt/data/file.txt".to_owned())); + assert_value_round_trip(&MontyObject::Decimal("1.20".to_owned())); + assert_value_round_trip(&MontyObject::Decimal("1E+5".to_owned())); + assert_value_round_trip(&MontyObject::Decimal("-0".to_owned())); + assert_value_round_trip(&MontyObject::Decimal("NaN".to_owned())); + assert_value_round_trip(&MontyObject::Decimal("-Infinity".to_owned())); + assert_value_round_trip(&MontyObject::Decimal(String::new())); } #[test] diff --git a/crates/monty-python/src/convert.rs b/crates/monty-python/src/convert.rs index b8a0c706d..4201e87c1 100644 --- a/crates/monty-python/src/convert.rs +++ b/crates/monty-python/src/convert.rs @@ -151,6 +151,9 @@ pub fn py_to_monty(obj: &Bound<'_, PyAny>, dc_registry: &DcRegistry, mut depth: Ok(MontyObject::TimeDelta(py_timedelta_to_monty(delta))) } else if obj.is_instance(get_datetime_timezone_type(obj.py())?)? { py_timezone_to_monty(obj).map(MontyObject::TimeZone) + } else if obj.is_instance(get_decimal(obj.py())?)? { + // Carry the host `Decimal` as its canonical `str()` (lossless). + Ok(MontyObject::Decimal(obj.str()?.to_string())) } else if let Ok(exc) = obj.cast::() { Ok(exc_to_monty_object(exc)) } else if is_dataclass(obj) { @@ -236,6 +239,7 @@ fn round_trip_type_table(py: Python<'_>) -> PyResult<&'static Vec<(Py, Mo MontyType::DateTime, MontyType::TimeDelta, MontyType::TimeZone, + MontyType::Decimal, MontyType::RePattern, MontyType::ReMatch, MontyType::TextIOWrapper, @@ -368,6 +372,7 @@ pub(crate) fn monty_to_py_inner( let exc = exc_monty_to_py(py, MontyException::new(*exc_type, arg.clone())); Ok(exc.into_value(py).into_any()) } + MontyObject::Decimal(s) => get_decimal(py)?.call1((s,)).map(Bound::unbind), MontyObject::Date(date) => PyDate::new(py, date.year, date.month, date.day) .map(Bound::into_any) .map(Bound::unbind), @@ -435,6 +440,7 @@ fn type_object_to_py(py: Python<'_>, t: MontyType) -> PyResult> { MontyType::DateTime => cached!("datetime", "datetime"), MontyType::TimeDelta => cached!("datetime", "timedelta"), MontyType::TimeZone => cached!("datetime", "timezone"), + MontyType::Decimal => cached!("decimal", "Decimal"), // Consistent with the Path *instance* arm, which marshals as PurePosixPath // and is instantiable on every host OS (unlike PosixPath on Windows). MontyType::Path => get_pure_posix_path(py).map(|b| b.clone().unbind()), @@ -638,6 +644,13 @@ fn get_datetime_timezone_utc(py: Python<'_>) -> PyResult<&Py> { }) } +/// Cached import of the host `decimal.Decimal` class. +fn get_decimal(py: Python<'_>) -> PyResult<&Bound<'_, PyAny>> { + static DECIMAL: PyOnceLock> = PyOnceLock::new(); + + DECIMAL.import(py, "decimal", "Decimal") +} + /// Cached import of `collections.namedtuple` function. fn get_namedtuple(py: Python<'_>) -> PyResult<&Bound<'_, PyAny>> { static NAMEDTUPLE: PyOnceLock> = PyOnceLock::new(); diff --git a/crates/monty-python/src/exceptions.rs b/crates/monty-python/src/exceptions.rs index c4afba3f9..4996e9ffa 100644 --- a/crates/monty-python/src/exceptions.rs +++ b/crates/monty-python/src/exceptions.rs @@ -553,6 +553,38 @@ pub fn exc_monty_to_py(py: Python<'_>, mut exc: MontyException) -> PyErr { exceptions::PyRuntimeError::new_err(msg) } } + // Reconstruct the host `decimal` exceptions by name; fall back to their + // common `ArithmeticError` parent if the `decimal` module can't be reached. + ExcType::DecimalException => decimal_exc(py, "DecimalException", msg), + ExcType::DecimalInvalidOperation => decimal_exc(py, "InvalidOperation", msg), + ExcType::DecimalDivisionByZero => decimal_exc(py, "DivisionByZero", msg), + ExcType::DecimalOverflow => decimal_exc(py, "Overflow", msg), + ExcType::DecimalInexact => decimal_exc(py, "Inexact", msg), + ExcType::DecimalRounded => decimal_exc(py, "Rounded", msg), + ExcType::DecimalSubnormal => decimal_exc(py, "Subnormal", msg), + ExcType::DecimalClamped => decimal_exc(py, "Clamped", msg), + ExcType::DecimalUnderflow => decimal_exc(py, "Underflow", msg), + ExcType::DecimalFloatOperation => decimal_exc(py, "FloatOperation", msg), + // `InvalidOperation` condition subtypes. Monty raises plain + // `InvalidOperation` rather than these, but the variants exist for the + // hierarchy, so reconstruct them by name (they resolve under `decimal`). + ExcType::DecimalConversionSyntax => decimal_exc(py, "ConversionSyntax", msg), + ExcType::DecimalDivisionImpossible => decimal_exc(py, "DivisionImpossible", msg), + ExcType::DecimalDivisionUndefined => decimal_exc(py, "DivisionUndefined", msg), + ExcType::DecimalInvalidContext => decimal_exc(py, "InvalidContext", msg), + } +} + +/// Reconstructs a host `decimal.` exception with `msg`, falling back to +/// `ArithmeticError` (its common parent in Monty's `is_subclass_of`) if the +/// `decimal` module can't be reached. +fn decimal_exc(py: Python<'_>, name: &str, msg: String) -> PyErr { + if let Ok(cls) = py.import("decimal").and_then(|m| m.getattr(name)) + && let Ok(exc_instance) = cls.call1((PyString::new(py, &msg),)) + { + PyErr::from_value(exc_instance) + } else { + exceptions::PyArithmeticError::new_err(msg) } } diff --git a/crates/monty-python/tests/test_decimal.py b/crates/monty-python/tests/test_decimal.py new file mode 100644 index 000000000..f20dc5885 --- /dev/null +++ b/crates/monty-python/tests/test_decimal.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from decimal import Decimal + +from conftest import RunMonty +from inline_snapshot import snapshot + + +def test_decimal_input_roundtrip(monty_run: RunMonty): + # A host Decimal crosses into the sandbox and back losslessly. + result = monty_run('x', inputs={'x': Decimal('1.20')}) + assert isinstance(result, Decimal) + assert (type(result).__name__, repr(result)) == snapshot(('Decimal', "Decimal('1.20')")) + + +def test_decimal_exponent_roundtrip(monty_run: RunMonty): + result = monty_run('x', inputs={'x': Decimal('1E+5')}) + assert (type(result).__name__, repr(result)) == snapshot(('Decimal', "Decimal('1E+5')")) + + +def test_decimal_special_roundtrip(monty_run: RunMonty): + result = monty_run('x', inputs={'x': Decimal('-Infinity')}) + assert repr(result) == snapshot("Decimal('-Infinity')") + + +def test_decimal_output(monty_run: RunMonty): + # A Decimal created in the sandbox is returned as a host Decimal. + result = monty_run("from decimal import Decimal\nDecimal('1.23') + Decimal('4.56')") + assert isinstance(result, Decimal) + assert (type(result).__name__, str(result)) == snapshot(('Decimal', '5.79')) + + +def test_decimal_division_prec(monty_run: RunMonty): + result = monty_run('from decimal import Decimal\nDecimal(1) / Decimal(3)') + assert str(result) == snapshot('0.3333333333333333333333333333') + + +def test_decimal_input_used_in_arithmetic(monty_run: RunMonty): + # A host Decimal input is used in sandbox arithmetic and returned. + result = monty_run('x * 2 + 1', inputs={'x': Decimal('1.5')}) + assert str(result) == snapshot('4.0') + + +def test_decimal_in_container(monty_run: RunMonty): + result = monty_run('[x, x + 1]', inputs={'x': Decimal('2.5')}) + assert [str(d) for d in result] == snapshot(['2.5', '3.5']) + + +def test_decimal_snan_payload_roundtrip(monty_run: RunMonty): + # Signaling NaNs and payload NaNs cross the boundary losslessly. + result = monty_run('x', inputs={'x': Decimal('-sNaN123')}) + assert repr(result) == snapshot("Decimal('-sNaN123')") + + +def test_decimal_class_roundtrip(monty_run: RunMonty): + # Returning the Decimal *class* maps to the host decimal.Decimal type. + result = monty_run('from decimal import Decimal\nDecimal') + assert result is Decimal diff --git a/crates/monty-type-checking/tests/good_types.py b/crates/monty-type-checking/tests/good_types.py index 8c0041024..5e2ef12dd 100644 --- a/crates/monty-type-checking/tests/good_types.py +++ b/crates/monty-type-checking/tests/good_types.py @@ -1,5 +1,6 @@ import asyncio import datetime +import decimal import json import os import re @@ -589,3 +590,57 @@ class Point: assert_type(json.loads('null'), Any) assert_type(json.dumps(None), str) + +# === decimal module === + +# construction from str / int / float / Decimal +dec = decimal.Decimal('1.5') +assert_type(dec, decimal.Decimal) +dec_i = decimal.Decimal(10) +dec_f = decimal.Decimal(0.1) +dec_copy = decimal.Decimal(dec) + +# str / repr / bool +check_str(str(dec)) +check_str(repr(dec)) +check_bool(bool(dec)) + +# arithmetic with Decimal and int operands +assert_type(dec + dec_i, decimal.Decimal) +assert_type(dec - 1, decimal.Decimal) +assert_type(dec * 2, decimal.Decimal) +assert_type(dec / dec_i, decimal.Decimal) +assert_type(dec % 2, decimal.Decimal) +assert_type(dec**2, decimal.Decimal) +assert_type(-dec, decimal.Decimal) +abs(dec) # `abs()` routes through `__abs__`; Monty's builtins stub infers Unknown + +# comparisons +check_bool(dec == dec_i) +check_bool(dec < dec_i) +check_bool(dec >= 1) + +# conversions +check_int(int(dec)) +check_float(float(dec)) + +# methods (including the per-call `rounding=` argument) +assert_type(dec.sqrt(), decimal.Decimal) +assert_type(dec.quantize(decimal.Decimal('0.01')), decimal.Decimal) +assert_type(dec.quantize(decimal.Decimal('0.01'), rounding=decimal.ROUND_HALF_UP), decimal.Decimal) +assert_type(dec.normalize(), decimal.Decimal) +assert_type(dec.to_integral_value(rounding=decimal.ROUND_FLOOR), decimal.Decimal) +check_bool(dec.is_nan()) +dec_tuple = dec.as_tuple() +check_int(dec_tuple.sign) + +# rounding-mode constants +check_str(decimal.ROUND_HALF_EVEN) + +# exceptions are catchable arithmetic errors +try: + dec.sqrt() +except decimal.InvalidOperation: + pass +except decimal.DecimalException: + pass diff --git a/crates/monty-typeshed/custom/decimal.pyi b/crates/monty-typeshed/custom/decimal.pyi new file mode 100644 index 000000000..0cd0685f6 --- /dev/null +++ b/crates/monty-typeshed/custom/decimal.pyi @@ -0,0 +1,108 @@ +# Upstream typeshed re-exports `decimal` from `_decimal.pyi`; Monty implements +# only a subset (a fixed default context — no `Context` objects or `getcontext` +# — and a reduced method set), so this stub describes *only* the surface Monty +# supports. Methods absent here (compare_total, fma, scaleb, shift, rotate, +# logb, next_*, …) are intentionally omitted so they type-error instead of +# silently passing. + +from collections.abc import Sequence +from typing import ClassVar, Final, Literal, NamedTuple, overload + +ROUND_DOWN: Final[str] +ROUND_HALF_UP: Final[str] +ROUND_HALF_EVEN: Final[str] +ROUND_CEILING: Final[str] +ROUND_FLOOR: Final[str] +ROUND_UP: Final[str] +ROUND_HALF_DOWN: Final[str] +ROUND_05UP: Final[str] + +class DecimalTuple(NamedTuple): + sign: int + digits: tuple[int, ...] + exponent: int | Literal['n', 'N', 'F'] + +class DecimalException(ArithmeticError): ... +class Clamped(DecimalException): ... +class InvalidOperation(DecimalException): ... +class ConversionSyntax(InvalidOperation): ... +class DivisionByZero(DecimalException, ZeroDivisionError): ... +class DivisionImpossible(InvalidOperation): ... +class DivisionUndefined(InvalidOperation, ZeroDivisionError): ... +class Inexact(DecimalException): ... +class InvalidContext(InvalidOperation): ... +class Rounded(DecimalException): ... +class Subnormal(DecimalException): ... +class Overflow(Inexact, Rounded): ... +class Underflow(Inexact, Rounded, Subnormal): ... +class FloatOperation(DecimalException, TypeError): ... + +# Accepted by `Decimal(...)`; the tuple form is `(sign, digits, exponent)`. +_DecimalNew = Decimal | float | str | tuple[int, Sequence[int], int] +# Accepted as the *other* operand in arithmetic/comparison (Decimal ⊕ int). +_ComparableNum = Decimal | int + +class Decimal: + # `from_float(x)` is not implemented; use `Decimal(x)` directly (it accepts + # `float` and produces the identical exact value). See `limitations/decimal.md`. + def __new__(cls, value: _DecimalNew = ...) -> Decimal: ... + def __str__(self) -> str: ... + def __repr__(self) -> str: ... + def __bool__(self) -> bool: ... + def __hash__(self) -> int: ... + def __eq__(self, value: object, /) -> bool: ... + def __lt__(self, value: _ComparableNum, /) -> bool: ... + def __le__(self, value: _ComparableNum, /) -> bool: ... + def __gt__(self, value: _ComparableNum, /) -> bool: ... + def __ge__(self, value: _ComparableNum, /) -> bool: ... + def __add__(self, value: _ComparableNum, /) -> Decimal: ... + def __radd__(self, value: int, /) -> Decimal: ... + def __sub__(self, value: _ComparableNum, /) -> Decimal: ... + def __rsub__(self, value: int, /) -> Decimal: ... + def __mul__(self, value: _ComparableNum, /) -> Decimal: ... + def __rmul__(self, value: int, /) -> Decimal: ... + def __truediv__(self, value: _ComparableNum, /) -> Decimal: ... + def __rtruediv__(self, value: int, /) -> Decimal: ... + def __floordiv__(self, value: _ComparableNum, /) -> Decimal: ... + def __rfloordiv__(self, value: int, /) -> Decimal: ... + def __mod__(self, value: _ComparableNum, /) -> Decimal: ... + def __rmod__(self, value: int, /) -> Decimal: ... + def __divmod__(self, value: _ComparableNum, /) -> tuple[Decimal, Decimal]: ... + def __rdivmod__(self, value: int, /) -> tuple[Decimal, Decimal]: ... + def __pow__(self, value: _ComparableNum, /) -> Decimal: ... + def __rpow__(self, value: int, /) -> Decimal: ... + def __neg__(self) -> Decimal: ... + def __pos__(self) -> Decimal: ... + def __abs__(self) -> Decimal: ... + def __int__(self) -> int: ... + def __float__(self) -> float: ... + @overload + def __round__(self) -> int: ... + @overload + def __round__(self, ndigits: int, /) -> Decimal: ... + def __format__(self, specifier: str, /) -> str: ... + # Monty has no `Context` objects, so the CPython `context=` argument is not + # accepted anywhere (passing one raises `TypeError`); the per-call + # `rounding=` argument is supported where shown below — everything else runs + # under the fixed default context (see `limitations/decimal.md`). + def quantize(self, exp: _ComparableNum, rounding: str | None = None) -> Decimal: ... + def normalize(self) -> Decimal: ... + def to_integral_value(self, rounding: str | None = None) -> Decimal: ... + def sqrt(self) -> Decimal: ... + def ln(self) -> Decimal: ... + def log10(self) -> Decimal: ... + def exp(self) -> Decimal: ... + def as_tuple(self) -> DecimalTuple: ... + def is_nan(self) -> bool: ... + def is_qnan(self) -> bool: ... + def is_snan(self) -> bool: ... + def is_infinite(self) -> bool: ... + def is_finite(self) -> bool: ... + def is_zero(self) -> bool: ... + def is_signed(self) -> bool: ... + def copy_abs(self) -> Decimal: ... + def copy_negate(self) -> Decimal: ... + def copy_sign(self, other: _ComparableNum, /) -> Decimal: ... + def adjusted(self) -> int: ... + +__all__: ClassVar[list[str]] diff --git a/crates/monty-typeshed/update.py b/crates/monty-typeshed/update.py index b6dce1ddd..23c62a8d6 100644 --- a/crates/monty-typeshed/update.py +++ b/crates/monty-typeshed/update.py @@ -142,6 +142,7 @@ collections: 3.0- dataclasses: 3.7- datetime: 3.0- +decimal: 3.0- json: 3.0- math: 3.0- os: 3.0- diff --git a/crates/monty-typeshed/vendor/typeshed/stdlib/VERSIONS b/crates/monty-typeshed/vendor/typeshed/stdlib/VERSIONS index f71bb475d..7a23ed339 100644 --- a/crates/monty-typeshed/vendor/typeshed/stdlib/VERSIONS +++ b/crates/monty-typeshed/vendor/typeshed/stdlib/VERSIONS @@ -10,6 +10,7 @@ builtins: 3.0- collections: 3.0- dataclasses: 3.7- datetime: 3.0- +decimal: 3.0- json: 3.0- math: 3.0- os: 3.0- diff --git a/crates/monty-typeshed/vendor/typeshed/stdlib/decimal.pyi b/crates/monty-typeshed/vendor/typeshed/stdlib/decimal.pyi new file mode 100644 index 000000000..0cd0685f6 --- /dev/null +++ b/crates/monty-typeshed/vendor/typeshed/stdlib/decimal.pyi @@ -0,0 +1,108 @@ +# Upstream typeshed re-exports `decimal` from `_decimal.pyi`; Monty implements +# only a subset (a fixed default context — no `Context` objects or `getcontext` +# — and a reduced method set), so this stub describes *only* the surface Monty +# supports. Methods absent here (compare_total, fma, scaleb, shift, rotate, +# logb, next_*, …) are intentionally omitted so they type-error instead of +# silently passing. + +from collections.abc import Sequence +from typing import ClassVar, Final, Literal, NamedTuple, overload + +ROUND_DOWN: Final[str] +ROUND_HALF_UP: Final[str] +ROUND_HALF_EVEN: Final[str] +ROUND_CEILING: Final[str] +ROUND_FLOOR: Final[str] +ROUND_UP: Final[str] +ROUND_HALF_DOWN: Final[str] +ROUND_05UP: Final[str] + +class DecimalTuple(NamedTuple): + sign: int + digits: tuple[int, ...] + exponent: int | Literal['n', 'N', 'F'] + +class DecimalException(ArithmeticError): ... +class Clamped(DecimalException): ... +class InvalidOperation(DecimalException): ... +class ConversionSyntax(InvalidOperation): ... +class DivisionByZero(DecimalException, ZeroDivisionError): ... +class DivisionImpossible(InvalidOperation): ... +class DivisionUndefined(InvalidOperation, ZeroDivisionError): ... +class Inexact(DecimalException): ... +class InvalidContext(InvalidOperation): ... +class Rounded(DecimalException): ... +class Subnormal(DecimalException): ... +class Overflow(Inexact, Rounded): ... +class Underflow(Inexact, Rounded, Subnormal): ... +class FloatOperation(DecimalException, TypeError): ... + +# Accepted by `Decimal(...)`; the tuple form is `(sign, digits, exponent)`. +_DecimalNew = Decimal | float | str | tuple[int, Sequence[int], int] +# Accepted as the *other* operand in arithmetic/comparison (Decimal ⊕ int). +_ComparableNum = Decimal | int + +class Decimal: + # `from_float(x)` is not implemented; use `Decimal(x)` directly (it accepts + # `float` and produces the identical exact value). See `limitations/decimal.md`. + def __new__(cls, value: _DecimalNew = ...) -> Decimal: ... + def __str__(self) -> str: ... + def __repr__(self) -> str: ... + def __bool__(self) -> bool: ... + def __hash__(self) -> int: ... + def __eq__(self, value: object, /) -> bool: ... + def __lt__(self, value: _ComparableNum, /) -> bool: ... + def __le__(self, value: _ComparableNum, /) -> bool: ... + def __gt__(self, value: _ComparableNum, /) -> bool: ... + def __ge__(self, value: _ComparableNum, /) -> bool: ... + def __add__(self, value: _ComparableNum, /) -> Decimal: ... + def __radd__(self, value: int, /) -> Decimal: ... + def __sub__(self, value: _ComparableNum, /) -> Decimal: ... + def __rsub__(self, value: int, /) -> Decimal: ... + def __mul__(self, value: _ComparableNum, /) -> Decimal: ... + def __rmul__(self, value: int, /) -> Decimal: ... + def __truediv__(self, value: _ComparableNum, /) -> Decimal: ... + def __rtruediv__(self, value: int, /) -> Decimal: ... + def __floordiv__(self, value: _ComparableNum, /) -> Decimal: ... + def __rfloordiv__(self, value: int, /) -> Decimal: ... + def __mod__(self, value: _ComparableNum, /) -> Decimal: ... + def __rmod__(self, value: int, /) -> Decimal: ... + def __divmod__(self, value: _ComparableNum, /) -> tuple[Decimal, Decimal]: ... + def __rdivmod__(self, value: int, /) -> tuple[Decimal, Decimal]: ... + def __pow__(self, value: _ComparableNum, /) -> Decimal: ... + def __rpow__(self, value: int, /) -> Decimal: ... + def __neg__(self) -> Decimal: ... + def __pos__(self) -> Decimal: ... + def __abs__(self) -> Decimal: ... + def __int__(self) -> int: ... + def __float__(self) -> float: ... + @overload + def __round__(self) -> int: ... + @overload + def __round__(self, ndigits: int, /) -> Decimal: ... + def __format__(self, specifier: str, /) -> str: ... + # Monty has no `Context` objects, so the CPython `context=` argument is not + # accepted anywhere (passing one raises `TypeError`); the per-call + # `rounding=` argument is supported where shown below — everything else runs + # under the fixed default context (see `limitations/decimal.md`). + def quantize(self, exp: _ComparableNum, rounding: str | None = None) -> Decimal: ... + def normalize(self) -> Decimal: ... + def to_integral_value(self, rounding: str | None = None) -> Decimal: ... + def sqrt(self) -> Decimal: ... + def ln(self) -> Decimal: ... + def log10(self) -> Decimal: ... + def exp(self) -> Decimal: ... + def as_tuple(self) -> DecimalTuple: ... + def is_nan(self) -> bool: ... + def is_qnan(self) -> bool: ... + def is_snan(self) -> bool: ... + def is_infinite(self) -> bool: ... + def is_finite(self) -> bool: ... + def is_zero(self) -> bool: ... + def is_signed(self) -> bool: ... + def copy_abs(self) -> Decimal: ... + def copy_negate(self) -> Decimal: ... + def copy_sign(self, other: _ComparableNum, /) -> Decimal: ... + def adjusted(self) -> int: ... + +__all__: ClassVar[list[str]] diff --git a/crates/monty/src/builtins/abs.rs b/crates/monty/src/builtins/abs.rs index 24c5ad679..081f96171 100644 --- a/crates/monty/src/builtins/abs.rs +++ b/crates/monty/src/builtins/abs.rs @@ -10,7 +10,7 @@ use crate::{ exception_private::{ExcType, RunResult, SimpleException}, heap::HeapData, resource::ResourceTracker, - types::{LongInt, timedelta}, + types::{LongInt, decimal, timedelta}, value::Value, }; @@ -37,6 +37,7 @@ pub fn builtin_abs(vm: &mut VM<'_, impl ResourceTracker>, args: ArgValues) -> Ru Value::Bool(b) => Ok(Value::Int(i64::from(*b))), Value::Ref(id) => match vm.heap.get(*id) { HeapData::LongInt(li) => Ok(li.abs().into_value(vm.heap)?), + HeapData::Decimal(d) => decimal::abs(d.clone(), vm), HeapData::TimeDelta(td) => { let total = timedelta::total_microseconds(td); let abs_total = total.checked_abs().unwrap_or(total); diff --git a/crates/monty/src/builtins/divmod.rs b/crates/monty/src/builtins/divmod.rs index 2bedd63e0..0bed6e3a6 100644 --- a/crates/monty/src/builtins/divmod.rs +++ b/crates/monty/src/builtins/divmod.rs @@ -8,10 +8,10 @@ use crate::{ args::ArgValues, bytecode::VM, defer_drop, - exception_private::{ExcType, RunResult, SimpleException}, + exception_private::{ExcType, RunError, RunResult}, heap::HeapData, resource::{ResourceTracker, check_div_size}, - types::{LongInt, allocate_tuple}, + types::{LongInt, PyTrait, allocate_tuple, decimal}, value::{Value, floor_divmod}, }; @@ -114,18 +114,30 @@ pub fn builtin_divmod(vm: &mut VM<'_, impl ResourceTracker>, args: ArgValues) -> )?) } } - _ => { - let a_type = a.py_type_name(vm); - let b_type = b.py_type_name(vm); - Err(SimpleException::new_msg( - ExcType::TypeError, - format!("unsupported operand type(s) for divmod(): '{a_type}' and '{b_type}'"), - ) - .into()) + (Value::Ref(id), other) if let HeapData::Decimal(dec) = vm.heap.get(*id) => { + match decimal::divmod(dec.clone(), other, false, vm)? { + Some(result) => Ok(result), + None => Err(divmod_unsupported(a, b, vm)), + } + } + (other, Value::Ref(id)) if let HeapData::Decimal(dec) = vm.heap.get(*id) => { + match decimal::divmod(dec.clone(), other, true, vm)? { + Some(result) => Ok(result), + None => Err(divmod_unsupported(a, b, vm)), + } } + _ => Err(divmod_unsupported(a, b, vm)), } } +/// `TypeError: unsupported operand type(s) for divmod(): '{a}' and '{b}'` via +/// the shared [`ExcType::binary_type_error`] formatter (`py_type_name` so a +/// user-defined class operand reports its class name; the `divmod()` op never +/// triggers the `+` concatenation special case). +fn divmod_unsupported(a: &Value, b: &Value, vm: &VM<'_, impl ResourceTracker>) -> RunError { + ExcType::binary_type_error("divmod()", a.py_type(vm), a.py_type_name(vm), b.py_type_name(vm)) +} + /// Computes Python-style floor division and modulo for BigInts. /// /// Uses `div_mod_floor` from num_integer for correct floor semantics. diff --git a/crates/monty/src/builtins/pow.rs b/crates/monty/src/builtins/pow.rs index 07a49ce34..f01362185 100644 --- a/crates/monty/src/builtins/pow.rs +++ b/crates/monty/src/builtins/pow.rs @@ -12,7 +12,7 @@ use crate::{ exception_private::{ExcType, RunResult, SimpleException}, heap::{Heap, HeapData}, resource::{ResourceTracker, check_pow_size}, - types::{LongInt, PyTrait}, + types::{LongInt, PyTrait, decimal}, value::Value, }; @@ -25,15 +25,22 @@ pub fn builtin_pow(vm: &mut VM<'_, impl ResourceTracker>, args: ArgValues) -> Ru defer_drop!(base, vm); defer_drop!(exp, vm); defer_drop!(modulus, vm); - let base = normalize_bool(base); - let exp = normalize_bool(exp); - match modulus { - Value::None => two_arg_pow(base, exp, vm), + Value::None => two_arg_pow(normalize_bool(base), normalize_bool(exp), vm), m => { - let m = normalize_bool(m); + // Decimal 3-arg pow: if any operand is a `Decimal`, all three must + // promote (bool / int / LongInt / Decimal) — `pow(Decimal(2), 3, 5)` + // works in CPython. A non-promotable operand raises the + // three-operand TypeError inside `pow3`, except when a `float` is + // present, which falls through to the integers-only TypeError + // below (CPython's `float.__pow__` fires first) — both matching + // CPython. Probed with the raw values so the error names a `bool` + // operand as 'bool', not 'int'. + if let Some(result) = decimal::pow3(base, exp, m, vm)? { + return Ok(result); + } // Three-argument pow: modular exponentiation - match (base, exp, m) { + match (normalize_bool(base), normalize_bool(exp), normalize_bool(m)) { (Value::Int(b), Value::Int(e), Value::Int(m_val)) => { let Some(m_nz) = NonZero::new(*m_val) else { return Err( @@ -156,6 +163,13 @@ fn checked_pow_i64(mut base: i64, mut exp: u32) -> Option { /// /// On overflow, promotes to LongInt instead of returning an error. fn two_arg_pow(base: &Value, exp: &Value, vm: &mut VM<'_, impl ResourceTracker>) -> RunResult { + // Decimal probe first — the same dispatch the `**` operator uses. `None` + // (no Decimal, or a non-promotable pairing like `Decimal ** float`) falls + // through: no arm below can capture a Decimal ref, so the `_` arm raises + // the identical "** or pow()" TypeError. + if let Some(result) = decimal::binary_op_value(base, exp, decimal::BinOp::Pow, vm)? { + return Ok(result); + } match (base, exp) { (Value::Int(b), Value::Int(e)) => int_pow_int(*b, *e, vm.heap), (Value::Int(b), Value::Ref(id)) if let HeapData::LongInt(li) = vm.heap.get(*id) => { diff --git a/crates/monty/src/builtins/round.rs b/crates/monty/src/builtins/round.rs index 18c8b12a4..b4b3cd9ff 100644 --- a/crates/monty/src/builtins/round.rs +++ b/crates/monty/src/builtins/round.rs @@ -5,7 +5,9 @@ use crate::{ bytecode::VM, defer_drop, exception_private::{ExcType, RunResult, SimpleException}, + heap::HeapData, resource::ResourceTracker, + types::decimal, value::Value, }; @@ -34,6 +36,16 @@ pub fn builtin_round(vm: &mut VM<'_, impl ResourceTracker>, args: ArgValues) -> Some(Value::Int(n)) => Some(*n), Some(Value::Bool(b)) => Some(i64::from(*b)), Some(v) => { + // `round(Decimal, huge_int)`: CPython converts `ndigits` to a C + // ssize_t and raises `OverflowError`. Other number types keep the + // generic message (their `ndigits` handling pre-dates `Decimal`). + if let Value::Ref(id) = v + && matches!(vm.heap.get(*id), HeapData::LongInt(_)) + && let Value::Ref(num_id) = number + && matches!(vm.heap.get(*num_id), HeapData::Decimal(_)) + { + return Err(ExcType::int_too_large_for_ssize_t()); + } let type_name = v.py_type_name(vm); return Err(SimpleException::new_msg( ExcType::TypeError, @@ -82,6 +94,14 @@ pub fn builtin_round(vm: &mut VM<'_, impl ResourceTracker>, args: ArgValues) -> } } } + // round(Decimal) -> int (HALF_EVEN); round(Decimal, n) -> Decimal. + Value::Ref(id) if let HeapData::Decimal(d) = vm.heap.get(*id) => { + let d = d.clone(); + match digits { + Some(ndigits) => decimal::round_with_digits(d, ndigits, vm), + None => decimal::round_to_int(d, vm), + } + } _ => { let type_name = number.py_type_name(vm); Err(SimpleException::new_msg( diff --git a/crates/monty/src/bytecode/vm/binary.rs b/crates/monty/src/bytecode/vm/binary.rs index 93c56294f..863e70523 100644 --- a/crates/monty/src/bytecode/vm/binary.rs +++ b/crates/monty/src/bytecode/vm/binary.rs @@ -38,7 +38,7 @@ impl VM<'_, T> { rhs.py_type_name(this), )) } - Err(e) => Err(e.into()), + Err(e) => Err(e), } } @@ -81,7 +81,7 @@ impl VM<'_, T> { rhs.py_type_name(this), )) } - Err(e) => Err(e.into()), + Err(e) => Err(e), } } diff --git a/crates/monty/src/bytecode/vm/compare.rs b/crates/monty/src/bytecode/vm/compare.rs index cc5c700e4..37ecd2e22 100644 --- a/crates/monty/src/bytecode/vm/compare.rs +++ b/crates/monty/src/bytecode/vm/compare.rs @@ -21,7 +21,7 @@ impl VM<'_, T> { let lhs = this.pop(); defer_drop!(lhs, this); - let result = lhs.py_eq(rhs, this)?; + let result = lhs.py_eq_operator(rhs, this)?; this.push(Value::Bool(result)); Ok(()) } @@ -35,7 +35,7 @@ impl VM<'_, T> { let lhs = this.pop(); defer_drop!(lhs, this); - let result = !lhs.py_eq(rhs, this)?; + let result = !lhs.py_eq_operator(rhs, this)?; this.push(Value::Bool(result)); Ok(()) } diff --git a/crates/monty/src/bytecode/vm/mod.rs b/crates/monty/src/bytecode/vm/mod.rs index c06001fd7..efca5d146 100644 --- a/crates/monty/src/bytecode/vm/mod.rs +++ b/crates/monty/src/bytecode/vm/mod.rs @@ -21,6 +21,8 @@ pub(crate) use call::CallResult; pub(crate) use recursion::{ContainsVM, DropWithVM, RecursionToken, VmGuard}; use scheduler::Scheduler; +#[cfg(feature = "memory-model-checks")] +use crate::value::RefDropPanicSuppressor; use crate::{ MontyObject, args::ArgValues, @@ -41,7 +43,7 @@ use crate::{ parse::CodeRange, resource::ResourceTracker, types::{ - Dict, LongInt, MontyIter, PyTrait, + Dict, LongInt, MontyIter, PyTrait, decimal, file::{PendingFileEffect, apply_buffer_store, apply_write_position}, timedelta, }, @@ -628,6 +630,32 @@ pub struct VMSnapshot { pending_file_effect: Option, } +/// Neutralises a snapshot's `Value::Ref`s when it is dropped without being +/// resumed — a legitimate embedder flow (`dump()` then discard the live +/// progress) and the fate of a freshly-`load`ed snapshot that is never run. A +/// `VMSnapshot` is self-contained: its refs point into the heap serialized +/// alongside it (`Snapshot` in `run_progress`, or the REPL's snapshot types), +/// which is discarded with it, so no live refcount is corrupted — but there is +/// no heap borrow here to route the refs through, so the drop can only be +/// *silenced*, not booked. This only affects the `memory-model-checks` guard +/// in [`Value`]'s `Drop`; production builds have no `Value::Drop` and discard +/// the values harmlessly. `VM::restore` empties the snapshot via `mem::take` +/// before this runs, so a resumed snapshot's refs move into the live VM and +/// are torn down properly there instead. +#[cfg(feature = "memory-model-checks")] +impl Drop for VMSnapshot { + fn drop(&mut self) { + let _suppress = RefDropPanicSuppressor::new(); + // Drop every Value-bearing field while the guard is active (`frames` + // holds no `Value`s, so it drops harmlessly after this body). + self.stack = Vec::new(); + self.globals = Vec::new(); + self.exception_stack = Vec::new(); + self.scheduler = Scheduler::default(); + self.pending_file_effect = None; + } +} + // ============================================================================ // Virtual Machine // ============================================================================ @@ -789,15 +817,18 @@ impl<'h, T: ResourceTracker> VM<'h, T> { /// * `interns` - Interns for looking up function code /// * `print_writer` - Writer for print output pub fn restore( - snapshot: VMSnapshot, + mut snapshot: VMSnapshot, module_code: &'h Code, heap: &'h mut HeapReader<'h, T>, interns: &'h Interns, print_writer: PrintWriter<'h>, ) -> Self { - // Reconstruct call frames from serialized form - let frames: Vec> = snapshot - .frames + // Move the Value-bearing fields out via `mem::take` rather than by + // value: `VMSnapshot`'s `Drop` (under `memory-model-checks`) forbids + // moving fields out, and the emptied snapshot must drop harmlessly + // once its real contents have transferred into this live VM. + // Reconstruct call frames from serialized form. + let frames: Vec> = mem::take(&mut snapshot.frames) .into_iter() .map(|sf| { let code = match sf.function_id { @@ -824,19 +855,19 @@ impl<'h, T: ResourceTracker> VM<'h, T> { let current_frame_depth = frames.len().saturating_sub(1); // root frame doesn't contribute to depth Self { - stack: snapshot.stack, - globals: snapshot.globals, + stack: mem::take(&mut snapshot.stack), + globals: mem::take(&mut snapshot.globals), frames, heap, interns, print_writer, - exception_stack: snapshot.exception_stack, + exception_stack: mem::take(&mut snapshot.exception_stack), instruction_ip: snapshot.instruction_ip, - scheduler: snapshot.scheduler, + scheduler: mem::take(&mut snapshot.scheduler), module_code: Some(module_code), ext_function_load_ip: None, json_string_cache: JsonStringCache::default(), - pending_file_effect: snapshot.pending_file_effect, + pending_file_effect: snapshot.pending_file_effect.take(), recursion_depth: current_frame_depth, namespace_scratch: Vec::new(), // Always default value at a restore boundary — see the `run_reentry_depth` field doc. @@ -1185,6 +1216,15 @@ impl<'h, T: ResourceTracker> VM<'h, T> { Err(e) => catch_sync!(self, cached_frame, e), } } + HeapData::Decimal(dec) => { + // `-decimal` rounds to the working precision, like every op. + let d = dec.clone(); + value.drop_with_heap(self); + match decimal::neg(d, self) { + Ok(v) => self.push(v), + Err(e) => catch_sync!(self, cached_frame, e), + } + } _ => { let value_type = value.py_type_name(self); value.drop_with_heap(self); @@ -1204,16 +1244,25 @@ impl<'h, T: ResourceTracker> VM<'h, T> { match value { Value::Int(_) | Value::Float(_) => self.push(value), Value::Bool(b) => self.push(Value::Int(i64::from(b))), - Value::Ref(id) => { - if matches!(self.heap.get(id), HeapData::LongInt(_)) { - // LongInt - return as-is (value already has correct refcount) - self.push(value); - } else { + Value::Ref(id) => match self.heap.get(id) { + // LongInt - return as-is (value already has correct refcount) + HeapData::LongInt(_) => self.push(value), + HeapData::Decimal(dec) => { + // `+decimal` is value-preserving but still rounds to + // the working precision (e.g. `+Decimal(0.1)`). + let d = dec.clone(); + value.drop_with_heap(self); + match decimal::pos(d, self) { + Ok(v) => self.push(v), + Err(e) => catch_sync!(self, cached_frame, e), + } + } + _ => { let value_type = value.py_type_name(self); value.drop_with_heap(self); catch_sync!(self, cached_frame, ExcType::unary_type_error("+", &value_type)); } - } + }, _ => { let value_type = value.py_type_name(self); value.drop_with_heap(self); diff --git a/crates/monty/src/exception_private.rs b/crates/monty/src/exception_private.rs index bfdc59aaa..1013d5c47 100644 --- a/crates/monty/src/exception_private.rs +++ b/crates/monty/src/exception_private.rs @@ -146,6 +146,73 @@ pub enum ExcType { /// representations into the required attributes. #[strum(serialize = "re.PatternError")] RePatternError, + + // --- decimal module --- + /// `decimal.DecimalException` — the base of the whole `decimal` exception + /// tree. In CPython it subclasses `ArithmeticError`; [`Self::is_subclass_of`] + /// models that plus its own subclasses. + #[strum(serialize = "decimal.DecimalException")] + DecimalException, + /// `decimal.InvalidOperation` — raised for an invalid operation such as + /// `Decimal('abc')` (bad syntax), `0/0`, or `inf - inf`. Subclass of + /// `DecimalException`. The *message* carries the CPython condition-class + /// list, e.g. `[]`. + #[strum(serialize = "decimal.InvalidOperation")] + DecimalInvalidOperation, + /// `decimal.DivisionByZero` — `x / 0`, `x // 0` for non-zero `x`. In CPython + /// it subclasses *both* `DecimalException` and `ZeroDivisionError`, so + /// `except ZeroDivisionError` catches it; [`Self::is_subclass_of`] models the + /// dual parentage. + #[strum(serialize = "decimal.DivisionByZero")] + DecimalDivisionByZero, + /// `decimal.Overflow` — a result whose adjusted exponent exceeds `Emax` + /// (999999, CPython's default). Subclass of `(Inexact, Rounded)` in CPython. + #[strum(serialize = "decimal.Overflow")] + DecimalOverflow, + /// `decimal.Inexact` — a result was rounded away non-zero digits. + #[strum(serialize = "decimal.Inexact")] + DecimalInexact, + /// `decimal.Rounded` — a result was rounded (even if exactly). + #[strum(serialize = "decimal.Rounded")] + DecimalRounded, + /// `decimal.Subnormal` — a result is subnormal (below `Emin`). + #[strum(serialize = "decimal.Subnormal")] + DecimalSubnormal, + /// `decimal.Clamped` — a result's exponent was clamped to fit. + #[strum(serialize = "decimal.Clamped")] + DecimalClamped, + /// `decimal.Underflow` — a subnormal result rounded toward zero. Subclass of + /// `(Inexact, Rounded, Subnormal)` in CPython. + #[strum(serialize = "decimal.Underflow")] + DecimalUnderflow, + /// `decimal.FloatOperation` — a `float` was mixed with a `Decimal` while the + /// (untrapped-by-default) signal is armed. Subclass of `(DecimalException, + /// TypeError)` in CPython. + #[strum(serialize = "decimal.FloatOperation")] + DecimalFloatOperation, + /// `decimal.ConversionSyntax` — the `InvalidOperation` condition for a string + /// that cannot be parsed into a `Decimal`. Monty never raises it as a + /// *distinct* type (it raises `InvalidOperation` with the condition in the + /// message, exactly as CPython does), but the class is importable and + /// catchable. Subclass of `InvalidOperation`. + #[strum(serialize = "decimal.ConversionSyntax")] + DecimalConversionSyntax, + /// `decimal.DivisionImpossible` — the `InvalidOperation` condition for an + /// integer-division result with too many digits. Importable/catchable but + /// never raised as a distinct type. Subclass of `InvalidOperation`. + #[strum(serialize = "decimal.DivisionImpossible")] + DecimalDivisionImpossible, + /// `decimal.DivisionUndefined` — the `0 / 0` condition. Importable/catchable + /// but never raised as a distinct type. Subclass of *both* `InvalidOperation` + /// and `ZeroDivisionError` in CPython, so `except ZeroDivisionError` catches + /// it. + #[strum(serialize = "decimal.DivisionUndefined")] + DecimalDivisionUndefined, + /// `decimal.InvalidContext` — the `InvalidOperation` condition for an invalid + /// context. Importable/catchable but never raised as a distinct type. + /// Subclass of `InvalidOperation`. + #[strum(serialize = "decimal.InvalidContext")] + DecimalInvalidContext, } impl ExcType { @@ -170,8 +237,38 @@ impl ExcType { Self::Exception => !matches!(self, Self::BaseException | Self::KeyboardInterrupt | Self::SystemExit), // LookupError catches KeyError and IndexError Self::LookupError => matches!(self, Self::KeyError | Self::IndexError), - // ArithmeticError catches ZeroDivisionError and OverflowError - Self::ArithmeticError => matches!(self, Self::ZeroDivisionError | Self::OverflowError), + // ArithmeticError catches ZeroDivisionError, OverflowError, and the + // whole decimal exception tree (DecimalException ⊂ ArithmeticError). + Self::ArithmeticError => { + matches!( + self, + Self::ZeroDivisionError | Self::OverflowError | Self::DecimalException + ) || self.is_decimal_exception() + } + // DecimalException catches every decimal-specific exception. + Self::DecimalException => self.is_decimal_exception(), + // decimal.InvalidOperation ⊃ its finer condition subtypes + // (ConversionSyntax / DivisionImpossible / DivisionUndefined / + // InvalidContext), so `except InvalidOperation` catches them. + Self::DecimalInvalidOperation => matches!( + self, + Self::DecimalConversionSyntax + | Self::DecimalDivisionImpossible + | Self::DecimalDivisionUndefined + | Self::DecimalInvalidContext + ), + // decimal.Inexact ⊃ Overflow, Underflow (CPython multi-parent). + Self::DecimalInexact => matches!(self, Self::DecimalOverflow | Self::DecimalUnderflow), + // decimal.Rounded ⊃ Overflow, Underflow. + Self::DecimalRounded => matches!(self, Self::DecimalOverflow | Self::DecimalUnderflow), + // decimal.Subnormal ⊃ Underflow. + Self::DecimalSubnormal => matches!(self, Self::DecimalUnderflow), + // decimal.FloatOperation also derives from TypeError in CPython. + Self::TypeError => matches!(self, Self::DecimalFloatOperation), + // decimal.DivisionByZero is also a ZeroDivisionError in CPython, so + // `except ZeroDivisionError` catches `Decimal(1) / Decimal(0)`; + // DivisionUndefined likewise derives from ZeroDivisionError. + Self::ZeroDivisionError => matches!(self, Self::DecimalDivisionByZero | Self::DecimalDivisionUndefined), // RuntimeError catches RecursionError and NotImplementedError Self::RuntimeError => matches!(self, Self::RecursionError | Self::NotImplementedError), // AttributeError catches FrozenInstanceError @@ -325,6 +422,191 @@ impl ExcType { .into() } + /// Whether this is one of the `decimal` signal/condition exceptions — + /// `DecimalException`'s complete subtree, excluding `DecimalException` + /// itself. One list so the `ArithmeticError` and `DecimalException` arms + /// of [`Self::is_subclass_of`] cannot drift apart. + fn is_decimal_exception(self) -> bool { + matches!( + self, + Self::DecimalInvalidOperation + | Self::DecimalDivisionByZero + | Self::DecimalOverflow + | Self::DecimalInexact + | Self::DecimalRounded + | Self::DecimalSubnormal + | Self::DecimalClamped + | Self::DecimalUnderflow + | Self::DecimalFloatOperation + | Self::DecimalConversionSyntax + | Self::DecimalDivisionImpossible + | Self::DecimalDivisionUndefined + | Self::DecimalInvalidContext + ) + } + + /// `decimal.InvalidOperation: []` — raised + /// when a string (or stringified int) cannot be parsed into a `Decimal`, + /// matching CPython's `Decimal('abc')` / `Decimal('')` behaviour. The + /// message embeds the condition-class list CPython attaches to the + /// exception's args. + #[must_use] + pub(crate) fn decimal_conversion_syntax() -> RunError { + SimpleException::new_msg( + Self::DecimalInvalidOperation, + "[]".to_owned(), + ) + .into() + } + + /// `decimal.InvalidOperation: []` — the + /// generic invalid-operation, e.g. an *ordering* comparison involving NaN + /// (`Decimal('NaN') < 1`), `inf - inf`, or `inf * 0`. Unlike `==`/`!=` + /// (which return `False`/`True` for NaN), CPython raises this for `<`, `<=`, + /// `>`, `>=`. + #[must_use] + pub(crate) fn decimal_invalid_operation() -> RunError { + SimpleException::new_msg( + Self::DecimalInvalidOperation, + "[]".to_owned(), + ) + .into() + } + + /// `decimal.DivisionByZero: []` — `x / 0` or + /// `x // 0` for a non-zero `x`. Catchable as `ZeroDivisionError`. + #[must_use] + pub(crate) fn decimal_division_by_zero() -> RunError { + SimpleException::new_msg( + Self::DecimalDivisionByZero, + "[]".to_owned(), + ) + .into() + } + + /// `decimal.InvalidOperation: []` — `0 / 0`, + /// `0 // 0`, `0 % 0`, `divmod(0, 0)`: division of zero by zero. The condition + /// class `DivisionUndefined` appears only in the message (it is an + /// `InvalidOperation` subclass in CPython). + #[must_use] + pub(crate) fn decimal_division_undefined() -> RunError { + SimpleException::new_msg( + Self::DecimalInvalidOperation, + "[]".to_owned(), + ) + .into() + } + + /// `decimal.InvalidOperation: [, ]` — `divmod(x, 0)` for a non-zero `x`. CPython + /// attaches *both* condition classes (the `//` part signals DivisionByZero, + /// the `%` part InvalidOperation). + #[must_use] + pub(crate) fn decimal_divmod_by_zero() -> RunError { + SimpleException::new_msg( + Self::DecimalInvalidOperation, + "[, ]".to_owned(), + ) + .into() + } + + /// `decimal.Overflow: []` — a result whose + /// adjusted exponent exceeds `Emax`, matching CPython's default context. + #[must_use] + pub(crate) fn decimal_overflow() -> RunError { + SimpleException::new_msg(Self::DecimalOverflow, "[]".to_owned()).into() + } + + /// `decimal.InvalidOperation: []` — `//`, + /// `%` or `divmod` whose integer quotient would need more digits than the + /// working precision (`Decimal('1e40') // 3`). The condition class + /// `DivisionImpossible` is an `InvalidOperation` subclass in CPython and appears + /// only in the message. + #[must_use] + pub(crate) fn decimal_division_impossible() -> RunError { + SimpleException::new_msg( + Self::DecimalInvalidOperation, + "[]".to_owned(), + ) + .into() + } + + /// `ValueError: cannot convert NaN to integer` — `int()` / `round()` of a + /// `Decimal('NaN')`, matching CPython's exact message. + #[must_use] + pub(crate) fn decimal_nan_to_int() -> RunError { + SimpleException::new_msg(Self::ValueError, "cannot convert NaN to integer".to_owned()).into() + } + + /// `OverflowError: cannot convert Infinity to integer` — `int()` / `round()` + /// of a `Decimal('Infinity')`, matching CPython's exact message. + #[must_use] + pub(crate) fn decimal_infinity_to_int() -> RunError { + SimpleException::new_msg(Self::OverflowError, "cannot convert Infinity to integer".to_owned()).into() + } + + /// `TypeError: conversion from {type} to Decimal is not supported` — raised + /// when `Decimal(x)` is given a type it cannot convert (e.g. `None`, a + /// list, a user-class instance). Takes the rendered type *name* (not a + /// [`Type`]) so `Type::Instance` operands resolve their class name first. + #[must_use] + pub(crate) fn decimal_unsupported_conversion(type_name: impl Display) -> RunError { + SimpleException::new_msg( + Self::TypeError, + format!("conversion from {type_name} to Decimal is not supported"), + ) + .into() + } + + /// `ValueError: invalid format string` — the single message CPython's + /// `Decimal.__format__` raises for *every* unsupported format spec (an + /// integer/string presentation code like `d`/`x`/`s`, or a grouping option + /// combined with `n`), unlike the per-type "Unknown format code" wording the + /// `int`/`float` formatters use. + #[must_use] + pub(crate) fn decimal_invalid_format_string() -> RunError { + SimpleException::new_msg(Self::ValueError, "invalid format string".to_owned()).into() + } + + /// `ValueError` for a `Decimal` coefficient (or NaN payload) exceeding + /// Monty's sandbox digit cap. No CPython equivalent — CPython accepts + /// arbitrarily long literals (documented in `limitations/decimal.md`). + #[must_use] + pub(crate) fn decimal_digits_limit() -> RunError { + SimpleException::new_msg( + Self::ValueError, + "Decimal value exceeds the limit of 4300 digits".to_owned(), + ) + .into() + } + + /// `OverflowError: Python int too large to convert to C ssize_t` — an + /// `int` argument beyond CPython's `ssize_t` where CPython converts one + /// (a huge `Decimal` tuple-form exponent, `round(Decimal, huge)`). + #[must_use] + pub(crate) fn int_too_large_for_ssize_t() -> RunError { + SimpleException::new_msg( + Self::OverflowError, + "Python int too large to convert to C ssize_t".to_owned(), + ) + .into() + } + + /// `TypeError: Cannot hash a signaling NaN value` — `hash(Decimal('sNaN'))`, + /// matching CPython's exact message. + #[must_use] + pub(crate) fn decimal_snan_hash() -> RunError { + SimpleException::new_msg(Self::TypeError, "Cannot hash a signaling NaN value".to_owned()).into() + } + + /// `ValueError: cannot convert signaling NaN to float` — + /// `float(Decimal('sNaN'))` (directly or via the `math` module), matching + /// CPython's exact message. + #[must_use] + pub(crate) fn decimal_snan_to_float() -> RunError { + SimpleException::new_msg(Self::ValueError, "cannot convert signaling NaN to float".to_owned()).into() + } + /// Creates a TypeError for awaiting a non-awaitable object. /// /// Matches CPython's format: `TypeError: '{type}' object can't be awaited` @@ -1428,6 +1710,30 @@ impl ExcType { SimpleException::new_msg(Self::TypeError, message).into() } + /// `TypeError: unsupported operand type(s) for ** or pow(): '{a}', '{b}', '{c}'` + /// — CPython's message when three-argument `pow()` finds no handler + /// (e.g. `pow(2, Decimal(3), 'x')`). + #[must_use] + pub(crate) fn pow3_type_error(base: impl Display, exp: impl Display, modulus: impl Display) -> RunError { + SimpleException::new_msg( + Self::TypeError, + format!("unsupported operand type(s) for ** or pow(): '{base}', '{exp}', '{modulus}'"), + ) + .into() + } + + /// `TypeError: can't multiply sequence by non-int of type '{type}'` — + /// CPython's message for repeating a sequence (`str`/`bytes`/`list`/ + /// `tuple`) by a non-integer (`'a' * Decimal(2)`, either operand order). + #[must_use] + pub(crate) fn sequence_repeat_non_int(type_name: impl Display) -> RunError { + SimpleException::new_msg( + Self::TypeError, + format!("can't multiply sequence by non-int of type '{type_name}'"), + ) + .into() + } + /// Creates a TypeError for unsupported unary operations. /// /// Uses CPython's format: `bad operand type for unary {op}: '{type}'` diff --git a/crates/monty/src/fstring.rs b/crates/monty/src/fstring.rs index 710d3cdfd..8c024a1dd 100644 --- a/crates/monty/src/fstring.rs +++ b/crates/monty/src/fstring.rs @@ -16,7 +16,7 @@ use crate::{ heap::HeapData, intern::StringId, resource::{ResourceTracker, check_repeat_size}, - types::{LongInt, PyTrait, Type, long_int::check_bits_str_digits_limit}, + types::{LongInt, PyTrait, Type, decimal, long_int::check_bits_str_digits_limit}, value::Value, }; @@ -664,9 +664,16 @@ pub fn format_with_spec( let numeric_finite = match value { Value::Int(_) => true, Value::Float(f) => f.is_finite(), - // A big integer formatted as a float is first converted to `f64`, - // so an attacker-chosen precision applies to it too — guard it. - Value::Ref(id) => matches!(vm.heap.get(*id), HeapData::LongInt(_)), + // A big integer formatted as a float is first converted to `f64`, and + // a finite `Decimal`'s `e`/`f`/`%` renderer pads its native digit + // string up to the precision — so an attacker-chosen precision + // applies to both and must be guarded. (Non-finite decimals ignore + // precision, like non-finite floats.) + Value::Ref(id) => match vm.heap.get(*id) { + HeapData::LongInt(_) => true, + HeapData::Decimal(d) => d.is_finite(), + _ => false, + }, _ => false, }; if numeric_finite { @@ -678,6 +685,21 @@ pub fn format_with_spec( } } + // `Decimal` has a self-contained formatter mirroring CPython's + // `Decimal.__format__`: it validates the spec itself (raising the single + // `invalid format string` CPython uses for every unsupported combination — + // an integer/string presentation code, or grouping with `n`) and renders + // from the value's native digits. Dispatch here, *after* the precision guard + // above but *before* the int/float validation below, whose error messages + // and presentation rules diverge from `Decimal`'s. + if let Value::Ref(id) = value + && let HeapData::Decimal(d) = vm.heap.get(*id) + { + // `__format__` rounds under the active context's rounding mode, matching + // CPython (its precision still comes from the spec, not the context). + return decimal::format_decimal(d, spec, vm.heap.tracker()); + } + // A `str` value is formatted entirely through the string mini-language: // `validate_string_spec` rejects (in CPython's precedence order) every flag // that is meaningless for text, then `format_string` applies precision, @@ -923,7 +945,7 @@ fn validate_grouping(grouping: Grouping, type_char: Option, value_type /// value (it falls back to `str()`). fn type_valid_for_value(type_char: Option, value_type: Type) -> bool { let is_int = matches!(value_type, Type::Int | Type::Bool); - let is_num = is_int || value_type == Type::Float; + let is_num = is_int || matches!(value_type, Type::Float); match type_char { None => true, Some(TypeChar::D | TypeChar::B | TypeChar::O | TypeChar::X | TypeChar::XUpper | TypeChar::C) => is_int, @@ -1791,7 +1813,7 @@ fn positive_sign_prefix(sign: Option) -> &'static str { /// [`is_rounded_zero`]. `z` is only ever set for float presentations (it is /// rejected for integer/string ones in `format_with_spec`), so the integer /// callers get the plain sign behaviour. -fn numeric_sign(is_negative: bool, abs_str: &str, spec: &ParsedFormatSpec) -> &'static str { +pub(crate) fn numeric_sign(is_negative: bool, abs_str: &str, spec: &ParsedFormatSpec) -> &'static str { if is_negative && !(spec.z && is_rounded_zero(abs_str)) { "-" } else { @@ -1862,7 +1884,7 @@ fn maybe_alternate_point(abs_str: String, abs_val: f64, spec: &ParsedFormatSpec) /// integer digits before padding — see [`pad_signed_grouped`]. When /// `spec.frac_grouping` is set, the fractional digits are grouped first (a /// no-op for values with no fractional part, e.g. integers). -fn pad_signed_numeric(sign: &str, prefix: &str, abs_str: &str, spec: &ParsedFormatSpec) -> String { +pub(crate) fn pad_signed_numeric(sign: &str, prefix: &str, abs_str: &str, spec: &ParsedFormatSpec) -> String { // Fractional grouping is applied up front so the integer-grouping/padding // below treats the grouped fraction as an opaque suffix. let frac_grouped; diff --git a/crates/monty/src/heap.rs b/crates/monty/src/heap.rs index 73457309b..c8fa47983 100644 --- a/crates/monty/src/heap.rs +++ b/crates/monty/src/heap.rs @@ -21,7 +21,7 @@ use crate::{ types::{ BoundMethod, Bytes, Class, Dataclass, Dict, DictItemsView, DictKeysView, DictValuesView, FrozenSet, Instance, List, LongInt, Module, MontyIter, NamedTuple, OpenFile, Path, Range, ReMatch, RePattern, Set, Slice, Str, - TimeZone, Tuple, date, datetime, timedelta, timezone, + TimeZone, Tuple, date, datetime, decimal, timedelta, timezone, }, value::Value, }; @@ -238,6 +238,7 @@ pub enum HeapReadOutput<'a> { DateTime(HeapRead<'a, datetime::DateTime>), TimeDelta(HeapRead<'a, timedelta::TimeDelta>), TimeZone(HeapRead<'a, timezone::TimeZone>), + Decimal(HeapRead<'a, decimal::Decimal>), } pub struct HeapRead<'a, T: ?Sized> { @@ -631,6 +632,7 @@ impl<'a> HeapPtr<'a> { HeapData::DateTime(d) => HeapReadOutput::DateTime(heap_read(base, d, readers)), HeapData::TimeDelta(d) => HeapReadOutput::TimeDelta(heap_read(base, d, readers)), HeapData::TimeZone(d) => HeapReadOutput::TimeZone(heap_read(base, d, readers)), + HeapData::Decimal(d) => HeapReadOutput::Decimal(heap_read(base, d, readers)), } } } diff --git a/crates/monty/src/heap_data.rs b/crates/monty/src/heap_data.rs index edce6350c..f8372ff01 100644 --- a/crates/monty/src/heap_data.rs +++ b/crates/monty/src/heap_data.rs @@ -20,7 +20,7 @@ use crate::{ types::{ BoundMethod, Bytes, Class, Dataclass, Dict, DictItemsView, DictKeysView, DictValuesView, FrozenSet, Instance, LazyHeapSet, List, LongInt, Module, MontyIter, NamedTuple, OpenFile, Path, PyTrait, Range, ReMatch, RePattern, - Set, Slice, Str, Tuple, Type, date, datetime, + Set, Slice, Str, Tuple, Type, date, datetime, decimal, str::{allocate_string, concat_allocate_str}, timedelta, timezone, }, @@ -153,6 +153,12 @@ pub(crate) enum HeapData { TimeDelta(timedelta::TimeDelta), /// A fixed-offset `datetime.timezone` value. TimeZone(timezone::TimeZone), + /// A `decimal.Decimal` value: sign + `BigInt` coefficient + exponent. + /// + /// Inline (not boxed): the struct is 48 bytes, well under the largest + /// variants, so it does not grow `size_of::()`. + /// Leaf type: no heap references, not GC-tracked. + Decimal(decimal::Decimal), } impl HeapData { @@ -237,6 +243,7 @@ impl HeapData { Self::DateTime(_) => Type::DateTime, Self::TimeDelta(_) => Type::TimeDelta, Self::TimeZone(_) => Type::TimeZone, + Self::Decimal(_) => Type::Decimal, } } @@ -278,6 +285,7 @@ impl HeapData { Self::DateTime(d) => d.py_estimate_size(), Self::TimeDelta(d) => d.py_estimate_size(), Self::TimeZone(d) => d.py_estimate_size(), + Self::Decimal(d) => d.py_estimate_size(), } } } @@ -489,6 +497,7 @@ impl<'h> PyTrait<'h> for HeapReadOutput<'h> { Self::RePattern(p) => p.py_bool(vm), Self::TimeDelta(td) => td.py_bool(vm), Self::Date(_) | Self::DateTime(_) | Self::TimeZone(_) => true, + Self::Decimal(d) => d.py_bool(vm), } } @@ -521,6 +530,7 @@ impl<'h> PyTrait<'h> for HeapReadOutput<'h> { HeapReadOutput::TimeDelta(td) => Ok(td.py_call_attr(self_id, vm, attr, args)?), HeapReadOutput::Date(d) => Ok(d.py_call_attr(self_id, vm, attr, args)?), HeapReadOutput::DateTime(dt) => Ok(dt.py_call_attr(self_id, vm, attr, args)?), + HeapReadOutput::Decimal(d) => Ok(d.py_call_attr(self_id, vm, attr, args)?), // Types without methods — return AttributeError _ => { args.drop_with_heap(vm); @@ -607,6 +617,7 @@ impl<'h> PyTrait<'h> for HeapReadOutput<'h> { Self::DateTime(d) => d.py_type(vm), Self::TimeDelta(d) => d.py_type(vm), Self::TimeZone(d) => d.py_type(vm), + Self::Decimal(d) => d.py_type(vm), } } @@ -674,6 +685,8 @@ impl<'h> PyTrait<'h> for HeapReadOutput<'h> { HeapReadOutput::DateTime(a) => a.py_eq_impl(other, vm), HeapReadOutput::TimeDelta(a) => a.py_eq_impl(other, vm), HeapReadOutput::TimeZone(a) => a.py_eq_impl(other, vm), + // `Decimal` compares exactly against the numeric tower. + HeapReadOutput::Decimal(a) => a.py_eq_impl(other, vm), // Identity-only types: equality is pure identity (handled before the // heap read in `Value::py_eq_impl`), so they never define `==` themselves. HeapReadOutput::Cell(_) @@ -716,6 +729,7 @@ impl<'h> PyTrait<'h> for HeapReadOutput<'h> { Self::DateTime(d) => d.py_hash(self_id, vm), Self::TimeDelta(d) => d.py_hash(self_id, vm), Self::TimeZone(d) => d.py_hash(self_id, vm), + Self::Decimal(d) => d.py_hash(self_id, vm), // Closure / FunctionDefaults: hash by function ID. Two equal // closures share the same `func_id`, so this is sufficient. Self::Closure(c) => { @@ -801,6 +815,7 @@ impl<'h> PyTrait<'h> for HeapReadOutput<'h> { Self::DateTime(d) => d.py_repr_fmt(f, vm, heap_ids), Self::TimeDelta(d) => d.py_repr_fmt(f, vm, heap_ids), Self::TimeZone(d) => d.py_repr_fmt(f, vm, heap_ids), + Self::Decimal(d) => d.py_repr_fmt(f, vm, heap_ids), } } @@ -823,16 +838,14 @@ impl<'h> PyTrait<'h> for HeapReadOutput<'h> { Self::DateTime(d) => d.py_str(vm), Self::TimeDelta(d) => d.py_str(vm), Self::TimeZone(d) => d.py_str(vm), + // Decimal's str differs from its repr (`1.20` vs `Decimal('1.20')`) + Self::Decimal(d) => d.py_str(vm), // All other types use repr _ => self.py_repr(vm), } } - fn py_add( - &self, - other: &Self, - vm: &mut VM<'h, impl ResourceTracker>, - ) -> Result, crate::ResourceError> { + fn py_add(&self, other: &Self, vm: &mut VM<'h, impl ResourceTracker>) -> RunResult> { match (self, other) { (HeapReadOutput::Str(a), HeapReadOutput::Str(b)) => Ok(Some(concat_allocate_str( a.get(vm.heap).as_str(), @@ -858,13 +871,13 @@ impl<'h> PyTrait<'h> for HeapReadOutput<'h> { | (HeapReadOutput::TimeDelta(td), HeapReadOutput::Date(d)) => { let d = *d.get(vm.heap); let td = *td.get(vm.heap); - date::py_add(d, td, vm.heap) + Ok(date::py_add(d, td, vm.heap)?) } (HeapReadOutput::DateTime(dt), HeapReadOutput::TimeDelta(td)) | (HeapReadOutput::TimeDelta(td), HeapReadOutput::DateTime(dt)) => { let dt = dt.get(vm.heap).clone(); let td = *td.get(vm.heap); - datetime::py_add(&dt, &td, vm.heap) + Ok(datetime::py_add(&dt, &td, vm.heap)?) } (HeapReadOutput::TimeDelta(a), HeapReadOutput::TimeDelta(b)) => { let total = timedelta::total_microseconds(a.get(vm.heap)) @@ -879,11 +892,7 @@ impl<'h> PyTrait<'h> for HeapReadOutput<'h> { } } - fn py_sub( - &self, - other: &Self, - vm: &mut VM<'h, impl ResourceTracker>, - ) -> Result, crate::ResourceError> { + fn py_sub(&self, other: &Self, vm: &mut VM<'h, impl ResourceTracker>) -> RunResult> { match (self, other) { (HeapReadOutput::LongInt(a), HeapReadOutput::LongInt(b)) => { let bi = a.get(vm.heap).inner() - b.get(vm.heap).inner(); @@ -893,12 +902,12 @@ impl<'h> PyTrait<'h> for HeapReadOutput<'h> { (HeapReadOutput::Date(a), HeapReadOutput::Date(b)) => { let a = *a.get(vm.heap); let b = *b.get(vm.heap); - date::py_sub_date(a, b, vm.heap) + Ok(date::py_sub_date(a, b, vm.heap)?) } (HeapReadOutput::DateTime(a), HeapReadOutput::DateTime(b)) => { let a = a.get(vm.heap).clone(); let b = b.get(vm.heap).clone(); - datetime::py_sub_datetime(&a, &b, vm.heap) + Ok(datetime::py_sub_datetime(&a, &b, vm.heap)?) } (HeapReadOutput::TimeDelta(a), HeapReadOutput::TimeDelta(b)) => { let total = timedelta::total_microseconds(a.get(vm.heap)) @@ -913,12 +922,12 @@ impl<'h> PyTrait<'h> for HeapReadOutput<'h> { (HeapReadOutput::Date(d), HeapReadOutput::TimeDelta(td)) => { let d = *d.get(vm.heap); let td = *td.get(vm.heap); - date::py_sub_timedelta(d, td, vm.heap) + Ok(date::py_sub_timedelta(d, td, vm.heap)?) } (HeapReadOutput::DateTime(dt), HeapReadOutput::TimeDelta(td)) => { let dt = dt.get(vm.heap).clone(); let td = *td.get(vm.heap); - datetime::py_sub_timedelta(&dt, &td, vm.heap) + Ok(datetime::py_sub_timedelta(&dt, &td, vm.heap)?) } _ => Ok(None), } diff --git a/crates/monty/src/intern.rs b/crates/monty/src/intern.rs index d53084956..6f1050245 100644 --- a/crates/monty/src/intern.rs +++ b/crates/monty/src/intern.rs @@ -756,6 +756,115 @@ pub enum StaticStrings { FalseRepr, #[strum(serialize = "Ellipsis")] EllipsisRepr, + // decimal module strings (appended at the end so existing `StringId`s are + // not shifted). Names double as values for the rounding-mode constants + // (`decimal.ROUND_HALF_EVEN == 'ROUND_HALF_EVEN'`). + /// Module name for `import decimal`. + Decimal, + /// `decimal.Decimal` class attribute name. + #[strum(serialize = "Decimal")] + DecimalClass, + /// `decimal.DecimalException` exception attribute name. + #[strum(serialize = "DecimalException")] + DecimalExceptionName, + /// `decimal.InvalidOperation` exception attribute name. + #[strum(serialize = "InvalidOperation")] + InvalidOperation, + /// `decimal.DivisionByZero` exception attribute name. + #[strum(serialize = "DivisionByZero")] + DivisionByZero, + /// `decimal.Overflow` exception attribute name. + #[strum(serialize = "Overflow")] + DecimalOverflowName, + /// `decimal.Inexact` exception attribute name. + #[strum(serialize = "Inexact")] + DecimalInexactName, + /// `decimal.Rounded` exception attribute name. + #[strum(serialize = "Rounded")] + DecimalRoundedName, + /// `decimal.Subnormal` exception attribute name. + #[strum(serialize = "Subnormal")] + DecimalSubnormalName, + /// `decimal.Clamped` exception attribute name. + #[strum(serialize = "Clamped")] + DecimalClampedName, + /// `decimal.Underflow` exception attribute name. + #[strum(serialize = "Underflow")] + DecimalUnderflowName, + /// `decimal.FloatOperation` exception attribute name. + #[strum(serialize = "FloatOperation")] + DecimalFloatOperationName, + /// `decimal.ROUND_CEILING` rounding-mode constant. + #[strum(serialize = "ROUND_CEILING")] + RoundCeiling, + /// `decimal.ROUND_FLOOR` rounding-mode constant. + #[strum(serialize = "ROUND_FLOOR")] + RoundFloor, + /// `decimal.ROUND_UP` rounding-mode constant. + #[strum(serialize = "ROUND_UP")] + RoundUp, + /// `decimal.ROUND_DOWN` rounding-mode constant. + #[strum(serialize = "ROUND_DOWN")] + RoundDown, + /// `decimal.ROUND_HALF_UP` rounding-mode constant. + #[strum(serialize = "ROUND_HALF_UP")] + RoundHalfUp, + /// `decimal.ROUND_HALF_DOWN` rounding-mode constant. + #[strum(serialize = "ROUND_HALF_DOWN")] + RoundHalfDown, + /// `decimal.ROUND_HALF_EVEN` rounding-mode constant (CPython default). + #[strum(serialize = "ROUND_HALF_EVEN")] + RoundHalfEven, + /// `decimal.ROUND_05UP` rounding-mode constant. + #[strum(serialize = "ROUND_05UP")] + Round05Up, + // Decimal method names (Sqrt/Exp/Log10 are reused from the math module + // block; Normalize from the unicodedata block). + Ln, + Quantize, + ToIntegralValue, + CopyAbs, + CopyNegate, + CopySign, + Adjusted, + AsTuple, + IsNan, + IsInfinite, + IsFinite, + IsZero, + IsSigned, + IsQnan, + IsSnan, + // `DecimalTuple` named-tuple type + field names returned by `as_tuple`. + // Explicit serialize: the enum's `snake_case` default would render the type + // name as `decimal_tuple`, but CPython's `repr` is `DecimalTuple(...)`. + #[strum(serialize = "DecimalTuple")] + DecimalTuple, + #[strum(serialize = "sign")] + DecimalSign, + #[strum(serialize = "digits")] + DecimalDigits, + #[strum(serialize = "exponent")] + DecimalExponent, + /// The `rounding` keyword argument accepted by `Decimal.quantize` / + /// `Decimal.to_integral_value`. + Rounding, + /// The `value` keyword argument of the `Decimal(...)` constructor. + Value, + /// The `other` argument of `Decimal.copy_sign(other)`. + Other, + /// `decimal.ConversionSyntax` exception attribute name. + #[strum(serialize = "ConversionSyntax")] + ConversionSyntax, + /// `decimal.DivisionImpossible` exception attribute name. + #[strum(serialize = "DivisionImpossible")] + DivisionImpossible, + /// `decimal.DivisionUndefined` exception attribute name. + #[strum(serialize = "DivisionUndefined")] + DivisionUndefined, + /// `decimal.InvalidContext` exception attribute name. + #[strum(serialize = "InvalidContext")] + InvalidContext, } impl StaticStrings { diff --git a/crates/monty/src/modules/decimal.rs b/crates/monty/src/modules/decimal.rs new file mode 100644 index 000000000..fd5dcd62d --- /dev/null +++ b/crates/monty/src/modules/decimal.rs @@ -0,0 +1,69 @@ +//! Implementation of the `decimal` module. +//! +//! Exposes the `Decimal` type, the full exception taxonomy, and the +//! rounding-mode string constants. Arithmetic, comparison and method behaviour +//! lives on the runtime [`Decimal`] type. Monty has no mutable `Context`: +//! arithmetic always runs under CPython's default context (`prec=28`, +//! `ROUND_HALF_EVEN`); methods that accept a per-call `rounding=` argument in +//! CPython accept it here too (see `limitations/decimal.md`). +//! +//! [`Decimal`]: crate::types::decimal::Decimal + +use crate::{ + builtins::Builtins, + bytecode::VM, + exception_private::ExcType, + heap::{HeapData, HeapId}, + intern::StaticStrings, + resource::{ResourceError, ResourceTracker}, + types::{Module, Type, decimal::ROUNDING_MODES}, + value::Value, +}; + +/// Creates the `decimal` module and allocates it on the heap. +/// +/// # Panics +/// +/// Panics if the required strings have not been pre-interned during prepare phase. +pub fn create_module(vm: &mut VM<'_, impl ResourceTracker>) -> Result { + let mut module = Module::new(StaticStrings::Decimal); + + // The Decimal type itself. + module.set_attr( + StaticStrings::DecimalClass, + Value::Builtin(Builtins::Type(Type::Decimal)), + vm, + ); + + // The full CPython exception taxonomy. `(name, ExcType)` pairs registered as + // module attributes; the subclass relationships (e.g. `Underflow ⊂ (Inexact, + // Rounded, Subnormal)`) live in `ExcType::is_subclass_of`. + for (name, exc) in [ + (StaticStrings::DecimalExceptionName, ExcType::DecimalException), + (StaticStrings::InvalidOperation, ExcType::DecimalInvalidOperation), + (StaticStrings::DivisionByZero, ExcType::DecimalDivisionByZero), + (StaticStrings::DecimalOverflowName, ExcType::DecimalOverflow), + (StaticStrings::DecimalInexactName, ExcType::DecimalInexact), + (StaticStrings::DecimalRoundedName, ExcType::DecimalRounded), + (StaticStrings::DecimalSubnormalName, ExcType::DecimalSubnormal), + (StaticStrings::DecimalClampedName, ExcType::DecimalClamped), + (StaticStrings::DecimalUnderflowName, ExcType::DecimalUnderflow), + (StaticStrings::DecimalFloatOperationName, ExcType::DecimalFloatOperation), + // The finer InvalidOperation condition subtypes. Importable/catchable so + // `except decimal.ConversionSyntax:` matches CPython; Monty raises + // `InvalidOperation` (with the condition in its message), never these. + (StaticStrings::ConversionSyntax, ExcType::DecimalConversionSyntax), + (StaticStrings::DivisionImpossible, ExcType::DecimalDivisionImpossible), + (StaticStrings::DivisionUndefined, ExcType::DecimalDivisionUndefined), + (StaticStrings::InvalidContext, ExcType::DecimalInvalidContext), + ] { + module.set_attr(name, Value::Builtin(Builtins::ExcType(exc)), vm); + } + + // Rounding-mode string constants. + for (rounding_mode, _) in ROUNDING_MODES { + module.set_attr(rounding_mode, Value::from(rounding_mode), vm); + } + + vm.heap.allocate(HeapData::Module(module)) +} diff --git a/crates/monty/src/modules/math.rs b/crates/monty/src/modules/math.rs index 450813c4d..3564a3f73 100644 --- a/crates/monty/src/modules/math.rs +++ b/crates/monty/src/modules/math.rs @@ -36,7 +36,7 @@ use crate::{ intern::StaticStrings, modules::ModuleFunctions, resource::{ResourceError, ResourceTracker}, - types::{LongInt, Module, allocate_tuple}, + types::{LongInt, Module, allocate_tuple, decimal}, value::Value, }; @@ -353,6 +353,11 @@ fn math_floor(vm: &mut VM<'_, impl ResourceTracker>, args: ArgValues) -> RunResu Value::Float(f) => float_to_int_checked(f.floor(), *f, vm.heap), Value::Int(n) => Ok(Value::Int(*n)), Value::Bool(b) => Ok(Value::Int(i64::from(*b))), + // `Decimal` defines `__floor__`; NaN/∞ raise the int() conversion errors. + Value::Ref(id) if let HeapData::Decimal(d) = vm.heap.get(*id) => { + let d = d.clone(); + decimal::floor_to_int(&d, vm) + } _ => Err(ExcType::type_error(format!( "must be real number, not {}", value.py_type_name(vm) @@ -372,6 +377,11 @@ fn math_ceil(vm: &mut VM<'_, impl ResourceTracker>, args: ArgValues) -> RunResul Value::Float(f) => float_to_int_checked(f.ceil(), *f, vm.heap), Value::Int(n) => Ok(Value::Int(*n)), Value::Bool(b) => Ok(Value::Int(i64::from(*b))), + // `Decimal` defines `__ceil__`; NaN/∞ raise the int() conversion errors. + Value::Ref(id) if let HeapData::Decimal(d) = vm.heap.get(*id) => { + let d = d.clone(); + decimal::ceil_to_int(&d, vm) + } _ => Err(ExcType::type_error(format!( "must be real number, not {}", value.py_type_name(vm) @@ -390,6 +400,11 @@ fn math_trunc(vm: &mut VM<'_, impl ResourceTracker>, args: ArgValues) -> RunResu Value::Float(f) => float_to_int_checked(f.trunc(), *f, vm.heap), Value::Int(n) => Ok(Value::Int(*n)), Value::Bool(b) => Ok(Value::Int(i64::from(*b))), + // `Decimal` defines `__trunc__` (identical to `int()`). + Value::Ref(id) if let HeapData::Decimal(d) = vm.heap.get(*id) => { + let d = d.clone(); + decimal::trunc_to_int(&d, vm) + } _ => Err(ExcType::type_error(format!( "type {} doesn't define __trunc__ method", value.py_type_name(vm) @@ -1360,6 +1375,10 @@ fn value_to_float(value: &Value, vm: &VM<'_, impl ResourceTracker>) -> RunResult Value::Float(f) => Ok(*f), Value::Int(n) => Ok(*n as f64), Value::Bool(b) => Ok(if *b { 1.0 } else { 0.0 }), + // `Decimal` defines `__float__`, so every float-consuming math + // function (`sqrt`, `isnan`, `log`, …) accepts it, as in CPython — + // including CPython's `ValueError` for a signaling NaN. + Value::Ref(id) if let HeapData::Decimal(d) = vm.heap.get(*id) => decimal::to_float(d), _ => Err(ExcType::type_error(format!( "must be real number, not {}", value.py_type_name(vm) diff --git a/crates/monty/src/modules/mod.rs b/crates/monty/src/modules/mod.rs index 988f12603..3ca8cd52c 100644 --- a/crates/monty/src/modules/mod.rs +++ b/crates/monty/src/modules/mod.rs @@ -18,6 +18,7 @@ use crate::{ pub(crate) mod asyncio; pub(crate) mod datetime; +pub(crate) mod decimal; #[cfg(feature = "test-hooks")] pub(crate) mod gc; pub(crate) mod json; @@ -53,6 +54,8 @@ pub(crate) enum StandardLib { Datetime, /// The `unicodedata` module providing Unicode Character Database access. Unicodedata, + /// The `decimal` module providing the `Decimal` type. + Decimal, /// The `gc` module exposing a single `collect()` for tests. Only present /// under the `test-hooks` feature so production sandboxes never see it. /// @@ -78,6 +81,7 @@ impl StandardLib { StaticStrings::Re => Some(Self::Re), StaticStrings::Datetime => Some(Self::Datetime), StaticStrings::Unicodedata => Some(Self::Unicodedata), + StaticStrings::Decimal => Some(Self::Decimal), #[cfg(feature = "test-hooks")] StaticStrings::Gc => Some(Self::Gc), _ => None, @@ -103,6 +107,7 @@ impl StandardLib { Self::Re => re::create_module(vm), Self::Datetime => datetime::create_module(vm), Self::Unicodedata => unicodedata::create_module(vm), + Self::Decimal => decimal::create_module(vm), #[cfg(feature = "test-hooks")] Self::Gc => gc::create_module(vm), } diff --git a/crates/monty/src/object.rs b/crates/monty/src/object.rs index 9c96ef413..10e0a68ff 100644 --- a/crates/monty/src/object.rs +++ b/crates/monty/src/object.rs @@ -25,7 +25,7 @@ use crate::{ types::{ Dataclass, LongInt, NamedTuple, OpenFile, Path, PyTrait, TimeZone, Type, allocate_tuple, bytes::{Bytes, bytes_repr}, - date as date_type, datetime as datetime_type, + date as date_type, datetime as datetime_type, decimal, dict::Dict, file::FileMode, instance::class_name, @@ -328,6 +328,14 @@ pub enum MontyObject { /// /// This is output-only and cannot be used as an input to `Executor::run()`. Cycle(usize, String), + /// Python `decimal.Decimal`, carried losslessly as its canonical string + /// (e.g. `"1.20"`, `"1E+5"`, `"NaN"`). Equality/hash at the boundary are + /// string-based, so `"1.2"` and `"1.20"` differ here even though they are + /// equal in-sandbox (documented in `limitations/decimal.md`). Appended at + /// the enum end: `MontyObject` is embedded in postcard snapshots, which + /// encode variants by index, so a mid-enum insertion would shift every + /// later variant. + Decimal(String), } /// The Python type of a value at the host boundary — the public mirror of the @@ -390,6 +398,10 @@ pub enum MontyType { Property, RePattern, ReMatch, + /// A `decimal.Decimal` value. Appended at the enum end: `MontyType` is + /// embedded in postcard snapshots/wire frames, which encode variants by + /// index, so a mid-enum insertion would shift every later variant. + Decimal, } impl fmt::Display for MontyType { @@ -444,6 +456,7 @@ impl MontyType { Self::DateTime => Some(Type::DateTime), Self::TimeDelta => Some(Type::TimeDelta), Self::TimeZone => Some(Type::TimeZone), + Self::Decimal => Some(Type::Decimal), Self::Str => Some(Type::Str), Self::Bytes => Some(Type::Bytes), Self::List => Some(Type::List), @@ -497,6 +510,7 @@ impl MontyType { Type::DateTime => Self::DateTime, Type::TimeDelta => Self::TimeDelta, Type::TimeZone => Self::TimeZone, + Type::Decimal => Self::Decimal, Type::Str => Self::Str, Type::Bytes => Self::Bytes, Type::List => Self::List, @@ -660,6 +674,9 @@ impl MontyObject { .map_err(|_| InvalidInputError::invalid_type("date"))?; Ok(Value::Ref(vm.heap.allocate(HeapData::Date(value))?)) } + Self::Decimal(s) => { + decimal::value_from_canonical_string(&s, vm).ok_or_else(|| InvalidInputError::invalid_type("Decimal")) + } Self::DateTime(datetime) => { let MontyDateTime { year, @@ -778,7 +795,7 @@ impl MontyObject { let names_len = |names: &[String]| -> usize { names.iter().map(|s| STR_OVERHEAD + s.len()).sum() }; let payload = match self { - Self::String(s) | Self::Path(s) | Self::Repr(s) => s.len(), + Self::String(s) | Self::Path(s) | Self::Repr(s) | Self::Decimal(s) => s.len(), Self::Cycle(_, placeholder) => placeholder.len(), Self::Bytes(b) => b.len(), // Saturate rather than truncate on a 32-bit `usize`: an over-large @@ -961,6 +978,7 @@ impl MontyObject { day: u8::try_from(day).expect("day is always 1..=31"), }) } + HeapReadOutput::Decimal(d) => Self::Decimal(decimal::canonical_string(d.get(vm.heap))), HeapReadOutput::DateTime(dt) => { if let Some((year, month, day, hour, minute, second, microsecond)) = datetime_type::to_components(dt.get(vm.heap)) @@ -1233,6 +1251,7 @@ impl MontyObject { } f.write_char(')') } + Self::Decimal(s) => write!(f, "Decimal('{s}')"), Self::Date(date) => write!(f, "datetime.date({}, {}, {})", date.year, date.month, date.day), Self::DateTime(datetime) => { write!( @@ -1369,6 +1388,7 @@ impl MontyObject { Self::Dict(d) => !d.is_empty(), Self::Set(s) => !s.is_empty(), Self::FrozenSet(fs) => !fs.is_empty(), + Self::Decimal(s) => decimal::string_is_truthy(s), Self::Date(_) => true, Self::DateTime(_) => true, Self::TimeDelta(delta) => delta.days != 0 || delta.seconds != 0 || delta.microseconds != 0, @@ -1402,6 +1422,7 @@ impl MontyObject { Self::Dict(_) => "dict", Self::Set(_) => "set", Self::FrozenSet(_) => "frozenset", + Self::Decimal(_) => "Decimal", Self::Date(_) => "date", Self::DateTime(_) => "datetime", Self::TimeDelta(_) => "timedelta", @@ -1446,6 +1467,7 @@ impl Hash for MontyObject { Self::Float(f) => f.to_bits().hash(state), Self::String(string) => string.hash(state), Self::Bytes(bytes) => bytes.hash(state), + Self::Decimal(s) => s.hash(state), Self::Date(date) => date.hash(state), Self::DateTime(datetime) => datetime.hash(state), Self::TimeDelta(delta) => delta.hash(state), @@ -1479,6 +1501,7 @@ impl PartialEq for MontyObject { (Self::Bytes(a), Self::Bytes(b)) => a == b, (Self::List(a), Self::List(b)) => a == b, (Self::Tuple(a), Self::Tuple(b)) => a == b, + (Self::Decimal(a), Self::Decimal(b)) => a == b, (Self::Date(a), Self::Date(b)) => a == b, (Self::DateTime(a), Self::DateTime(b)) => a == b, (Self::TimeDelta(a), Self::TimeDelta(b)) => a == b, diff --git a/crates/monty/src/repl.rs b/crates/monty/src/repl.rs index 69028a7b7..db9ab6c33 100644 --- a/crates/monty/src/repl.rs +++ b/crates/monty/src/repl.rs @@ -28,7 +28,7 @@ use crate::{ resource::ResourceTracker, run::Executor, run_progress::{ConvertedExit, ExtFunctionResult, NameLookupResult, convert_frame_exit}, - value::Value, + value::{Value, from_snapshot_bytes}, }; /// Stateful REPL session that executes snippets incrementally without replay. @@ -394,7 +394,7 @@ impl MontyRepl { /// # Errors /// Returns an error if deserialization fails. pub fn load(bytes: &[u8]) -> Result { - postcard::from_bytes(bytes) + from_snapshot_bytes(bytes) } } @@ -535,7 +535,7 @@ impl ReplProgress { /// # Errors /// Returns an error if deserialization fails. pub fn load(bytes: &[u8]) -> Result { - postcard::from_bytes(bytes) + from_snapshot_bytes(bytes) } } @@ -778,8 +778,13 @@ impl ReplResolveFutures { /// previously defined REPL bindings remain available. #[must_use] pub fn into_repl(self) -> MontyRepl { - let Self { mut repl, vm_state, .. } = self; - repl.globals = vm_state.globals; + let Self { + mut repl, mut vm_state, .. + } = self; + // `mem::take` (not a field move) because `VMSnapshot`'s Drop (under + // `memory-model-checks`) forbids moving fields out; the emptied + // snapshot then drops harmlessly. + repl.globals = mem::take(&mut vm_state.globals); repl } @@ -929,8 +934,13 @@ impl ReplSnapshot { /// This method creates an empty snapshot from just the globals so the REPL /// can be used for further snippets. fn into_repl(self) -> MontyRepl { - let Self { mut repl, vm_state, .. } = self; - repl.globals = vm_state.globals; + let Self { + mut repl, mut vm_state, .. + } = self; + // `mem::take` (not a field move) because `VMSnapshot`'s Drop (under + // `memory-model-checks`) forbids moving fields out; the emptied + // snapshot then drops harmlessly. + repl.globals = mem::take(&mut vm_state.globals); repl } diff --git a/crates/monty/src/run.rs b/crates/monty/src/run.rs index b62955aa6..30d50a19c 100644 --- a/crates/monty/src/run.rs +++ b/crates/monty/src/run.rs @@ -18,7 +18,7 @@ use crate::{ resource::{NoLimitTracker, ResourceTracker}, run_progress::{RunProgress, build_run_progress, check_snapshot_from_converted, convert_frame_exit}, types::str::StringRepr, - value::Value, + value::{Value, from_snapshot_bytes}, }; /// Primary interface for running Monty code. @@ -123,7 +123,7 @@ impl MontyRun { /// # Errors /// Returns an error if deserialization fails. pub fn load(bytes: &[u8]) -> Result { - postcard::from_bytes(bytes) + from_snapshot_bytes(bytes) } /// Starts execution with the given inputs and resource tracker, consuming self. diff --git a/crates/monty/src/run_progress.rs b/crates/monty/src/run_progress.rs index 31dca3500..9f6a3f3cc 100644 --- a/crates/monty/src/run_progress.rs +++ b/crates/monty/src/run_progress.rs @@ -21,6 +21,7 @@ use crate::{ os::OsFunctionCall, resource::ResourceTracker, run::Executor, + value::from_snapshot_bytes, }; // --------------------------------------------------------------------------- @@ -114,7 +115,7 @@ impl RunProgress { /// # Errors /// Returns an error if deserialization fails. pub fn load(bytes: &[u8]) -> Result { - postcard::from_bytes(bytes) + from_snapshot_bytes(bytes) } } diff --git a/crates/monty/src/types/decimal/arith.rs b/crates/monty/src/types/decimal/arith.rs new file mode 100644 index 000000000..29e4fb359 --- /dev/null +++ b/crates/monty/src/types/decimal/arith.rs @@ -0,0 +1,635 @@ +//! `Decimal` arithmetic — line-for-line ports of `_pydecimal.__add__` / +//! `__sub__` / `__mul__` / `__truediv__` / `_divide` / `__divmod__` / +//! `__mod__` / `__floordiv__` and the unary `__neg__` / `__pos__` / +//! `__abs__`, plus the `_WorkRep` / `_normalize` helpers they share. +//! +//! Every operator runs under the fixed context (prec 28, `ROUND_HALF_EVEN`). +//! CPython code paths that consult `context.rounding` (e.g. `__add__`'s +//! `ROUND_FLOOR` negative-zero rule) are kept and coded against a passed +//! [`RoundMode`] for faithfulness, with the operator entry points supplying +//! [`RoundMode::HalfEven`]. Results are finalised by [`fix::fix`] exactly +//! where CPython calls `._fix(context)`; untrapped signals +//! (`Inexact`/`Rounded`/`Clamped`/`Subnormal`/`Underflow`) have their call +//! sites omitted entirely (see the signal table in the module docs). +//! +//! Sandbox bounds: every `10**k` materialised here has `k` bounded by the +//! operands' digit counts (≤ [`super::DECIMAL_MAX_DIGITS`] + 1 each) plus the +//! precision — each bound is documented at its call site — never by a raw +//! exponent an attacker controls. + +use std::mem; + +use num_bigint::BigInt; +use num_integer::Integer; +use num_traits::{One, Zero}; +use smallvec::smallvec; + +use super::{ + DEFAULT_PREC, Decimal, ETINY, PREC, RoundMode, allocate, check_nans, fix, magnitude_digits, parse, pow, + pow10_bounded, +}; +use crate::{ + bytecode::VM, + exception_private::{ExcType, RunResult}, + heap::{Heap, HeapData}, + resource::ResourceTracker, + types::{Type, allocate_tuple}, + value::Value, +}; + +/// The binary arithmetic operators `Decimal` supports. Carried so the +/// zero-divisor and invalid-operation paths can pick the exact CPython +/// condition class. +#[derive(Clone, Copy)] +pub(crate) enum BinOp { + Add, + Sub, + Mul, + Div, + FloorDiv, + Mod, + Pow, +} + +/// The Decimal dispatch probe for the VM's binary operators — the single +/// point where `Value`-level arithmetic detects a `Decimal` operand. +/// +/// `Ok(None)` when neither operand is a heap `Decimal`, or when the *other* +/// operand is not a number `Decimal` operates with (a `float`, a `str`, …) — +/// in both cases the caller's remaining dispatch (and ultimately the VM's +/// generic `TypeError`) proceeds unchanged. Promotion runs *before* the +/// `Decimal` is cloned out of the heap, so an unsupported pairing +/// (`Decimal('1') + 1.5`) costs no coefficient allocation. Centralising the +/// probe also centralises the ordering invariant: callers invoke it before +/// any generic `Ref`-inspecting arm can capture a `Decimal` operand. +pub(crate) fn binary_op_value( + lhs: &Value, + rhs: &Value, + op: BinOp, + vm: &mut VM<'_, impl ResourceTracker>, +) -> RunResult> { + let (id, other, swapped) = if let Value::Ref(id) = lhs + && matches!(vm.heap.get(*id), HeapData::Decimal(_)) + { + (*id, rhs, false) + } else if let Value::Ref(id) = rhs + && matches!(vm.heap.get(*id), HeapData::Decimal(_)) + { + (*id, lhs, true) + } else { + return Ok(None); + }; + let Some(operand) = promote(other, vm.heap)? else { + // CPython special-cases sequence repetition: `'a' * Decimal(2)` (in + // either order) raises "can't multiply sequence by non-int of type + // 'decimal.Decimal'", not the generic unsupported-operands TypeError. + if matches!(op, BinOp::Mul) && is_sequence(other, vm.heap) { + return Err(ExcType::sequence_repeat_non_int(Type::Decimal)); + } + return Ok(None); + }; + // Just verified to be a Decimal above; `Ok(None)` is a defensive + // fallthrough (the VM would raise its generic TypeError). + let HeapData::Decimal(d) = vm.heap.get(id) else { + return Ok(None); + }; + let d = d.clone(); + let (a, b) = if swapped { (operand, d) } else { (d, operand) }; + compute(a, &b, op, vm).map(Some) +} + +/// Computes `a b` on promoted operands and allocates the result — the +/// core of [`binary_op_value`]. Every operator runs under the fixed +/// context's `ROUND_HALF_EVEN`. +fn compute(a: Decimal, b: &Decimal, op: BinOp, vm: &mut VM<'_, impl ResourceTracker>) -> RunResult { + let result = match op { + BinOp::Add => add(&a, b, RoundMode::HalfEven)?, + BinOp::Sub => sub(&a, b, RoundMode::HalfEven)?, + BinOp::Mul => mul(&a, b, RoundMode::HalfEven)?, + BinOp::Div => true_div(&a, b, RoundMode::HalfEven)?, + BinOp::FloorDiv => floor_div(&a, b, RoundMode::HalfEven)?, + BinOp::Mod => modulo(&a, b, RoundMode::HalfEven)?, + BinOp::Pow => return pow::power(a, b, vm), + }; + allocate(result, vm) +} + +/// Whether a value is one of the sequence types whose `*` CPython reports with +/// the "can't multiply sequence by non-int" TypeError (`str`, `bytes`, `list`, +/// `tuple` — named tuples included; `range` has no repeat and keeps the +/// generic message). +fn is_sequence(value: &Value, heap: &Heap) -> bool { + match value { + Value::InternString(_) | Value::InternBytes(_) => true, + Value::Ref(id) => matches!( + heap.get(*id), + HeapData::Str(_) | HeapData::Bytes(_) | HeapData::List(_) | HeapData::Tuple(_) | HeapData::NamedTuple(_) + ), + _ => false, + } +} + +/// Promotes a `Value` to a `Decimal` arithmetic operand, or `Ok(None)` if it +/// is not a number `Decimal` operates with. `float` is deliberately excluded +/// (so `Decimal + float` is a `TypeError`, matching CPython). A heap +/// `LongInt` converts exactly via [`parse::decimal_from_bigint`], so +/// `Decimal(1) + 10**100` works; only an int wider than +/// [`super::DECIMAL_MAX_DIGITS`] digits errors (the conversion's +/// Monty-specific `ValueError` propagates — a documented divergence). +pub(super) fn promote(value: &Value, heap: &Heap) -> RunResult> { + Ok(match value { + Value::Bool(b) => Some(Decimal::from_i64(i64::from(*b))), + Value::Int(i) => Some(Decimal::from_i64(*i)), + Value::Ref(id) => match heap.get(*id) { + HeapData::Decimal(d) => Some(d.clone()), + HeapData::LongInt(li) => Some(parse::decimal_from_bigint(li.inner())?), + _ => None, + }, + _ => None, + }) +} + +/// `divmod(decimal, other)` (or swapped) → `(a // b, a % b)` — port of +/// `_pydecimal.__divmod__` (1376-1418), sharing the [`divide`] core with `//` +/// and `%` so the three always agree. +/// +/// Returns `Ok(None)` when `other` is not a number `Decimal` operates with. +/// An infinite dividend raises `InvalidOperation` (CPython raises through a +/// tuple slot's `_raise_error`: `divmod(INF, INF)` from the first, `INF % x` +/// from the second — the armed trap fires either way). A zero divisor raises +/// the divmod-specific condition class: `divmod(0, 0)` is +/// `DivisionUndefined`, otherwise both `InvalidOperation` *and* +/// `DivisionByZero` are reported, matching CPython's combined message. +pub(crate) fn divmod( + d: Decimal, + other: &Value, + swapped: bool, + vm: &mut VM<'_, impl ResourceTracker>, +) -> RunResult> { + let Some(operand) = promote(other, vm.heap)? else { + return Ok(None); + }; + let (a, b) = if swapped { (operand, d) } else { (d, operand) }; + + let (quotient, remainder) = if let Some(nan) = check_nans(&a, Some(&b))? { + // A quiet NaN operand fills both slots. + (nan.clone(), nan) + } else if a.is_infinite() { + return Err(ExcType::decimal_invalid_operation()); + } else if b.is_zero() { + return Err(if a.is_zero() { + ExcType::decimal_division_undefined() // divmod(0, 0) + } else { + ExcType::decimal_divmod_by_zero() // x // 0 and x % 0 combined + }); + } else { + divide(&a, &b, RoundMode::HalfEven)? + }; + + // CPython `_fix`es the remainder; the quotient from `divide` is already + // canonical (≤ prec digits at exponent 0), so fixing it too is a no-op + // that keeps both results uniformly finalised. + let quotient = fix::fix(quotient, RoundMode::HalfEven)?; + let remainder = fix::fix(remainder, RoundMode::HalfEven)?; + let quotient = allocate(quotient, vm)?; + let remainder = allocate(remainder, vm)?; + Ok(Some(allocate_tuple(smallvec![quotient, remainder], vm.heap)?)) +} + +/// Unary `-decimal` — port of `_pydecimal.__neg__` (1045-1066), `fix`ed to +/// the working precision. Under any rounding mode but `ROUND_FLOOR`, negating +/// a zero yields a *positive* zero (`-Decimal('0')` and `-Decimal('-0')` are +/// both `Decimal('0')`) — CPython's negative-zero rule, applied here under +/// the fixed `HalfEven`. +pub(crate) fn neg(d: Decimal, vm: &mut VM<'_, impl ResourceTracker>) -> RunResult { + let result = neg_core(d, RoundMode::HalfEven)?; + allocate(result, vm) +} + +/// Unary `+decimal` — port of `_pydecimal.__pos__` (1067-1087). +/// Value-preserving but still rounds to the working precision (and normalises +/// `-0` to `0` outside `ROUND_FLOOR`), matching CPython. +pub(crate) fn pos(d: Decimal, vm: &mut VM<'_, impl ResourceTracker>) -> RunResult { + let result = pos_core(d, RoundMode::HalfEven)?; + allocate(result, vm) +} + +/// `abs(decimal)` — port of `_pydecimal.__abs__` (1088-1109) with its default +/// `round=True`: dispatches to `__neg__` / `__pos__` by sign (so the result +/// is rounded and `-0`-normalised), unlike the quiet `copy_abs`. +pub(crate) fn abs(d: Decimal, vm: &mut VM<'_, impl ResourceTracker>) -> RunResult { + let result = abs_core(d, RoundMode::HalfEven)?; + allocate(result, vm) +} + +/// The `__neg__` body, threaded over `rounding` for faithfulness to CPython's +/// `context.rounding` branch (operator callers pass `HalfEven`). Consumes `d` +/// (the sign flip moves the coefficient instead of cloning it). +fn neg_core(d: Decimal, rounding: RoundMode) -> RunResult { + if d.is_special() + && let Some(nan) = check_nans(&d, None)? + { + return Ok(nan); + } + let ans = if d.is_zero() && rounding != RoundMode::Floor { + // -Decimal('0') is Decimal('0'), not Decimal('-0'), except in + // ROUND_FLOOR rounding mode (`copy_abs`, by move). + Decimal { sign: 0, ..d } + } else { + // `copy_negate`, by move. + Decimal { sign: d.sign ^ 1, ..d } + }; + fix::fix(ans, rounding) +} + +/// The `__pos__` body — a rounded copy, with `+(-0)` giving `0` outside +/// `ROUND_FLOOR`. +fn pos_core(d: Decimal, rounding: RoundMode) -> RunResult { + if d.is_special() + && let Some(nan) = check_nans(&d, None)? + { + return Ok(nan); + } + let ans = if d.is_zero() && rounding != RoundMode::Floor { + // +(-0) = 0, except in ROUND_FLOOR rounding mode (`copy_abs`, by move). + Decimal { sign: 0, ..d } + } else { + d + }; + fix::fix(ans, rounding) +} + +/// The `__abs__(round=True)` body: `__neg__` for a negative value, `__pos__` +/// otherwise (each re-checks NaNs harmlessly, as CPython's do). +fn abs_core(d: Decimal, rounding: RoundMode) -> RunResult { + if d.is_signed() { + neg_core(d, rounding) + } else { + pos_core(d, rounding) + } +} + +/// `a + b` — port of `_pydecimal.__add__` (1110-1196). `-INF + INF` (either +/// order) raises `InvalidOperation`; a zero result of opposite-signed +/// operands is negative only under `ROUND_FLOOR` (the `negativezero` rule). +fn add(a: &Decimal, b: &Decimal, rounding: RoundMode) -> RunResult { + if a.is_special() || b.is_special() { + if let Some(nan) = check_nans(a, Some(b))? { + return Ok(nan); + } + if a.is_infinite() { + // If both INF, same sign => same as both, opposite => error. + if a.sign != b.sign && b.is_infinite() { + return Err(ExcType::decimal_invalid_operation()); + } + return Ok(a.clone()); + } + if b.is_infinite() { + return Ok(b.clone()); // can't both be infinity here + } + } + + let exp = a.exp.min(b.exp); + // If the answer is 0, the sign should be negative, in this case. + let negativezero = rounding == RoundMode::Floor && a.sign != b.sign; + + if a.is_zero() && b.is_zero() { + let sign = if negativezero { 1 } else { a.sign.min(b.sign) }; + return fix::fix(Decimal::from_triple(sign, BigInt::ZERO, exp), rounding); + } + if a.is_zero() { + let exp = exp.max(b.exp - PREC - 1); + return fix::fix(fix::rescale(b, exp, rounding)?, rounding); + } + if b.is_zero() { + let exp = exp.max(a.exp - PREC - 1); + return fix::fix(fix::rescale(a, exp, rounding)?, rounding); + } + + let (mut op1, mut op2) = normalize_ops(WorkRep::new(a), WorkRep::new(b)); + + let sign = if op1.sign != op2.sign { + // Equal and opposite. + if op1.int == op2.int { + return fix::fix( + Decimal::from_triple(u8::from(negativezero), BigInt::ZERO, exp), + rounding, + ); + } + if op1.int < op2.int { + mem::swap(&mut op1, &mut op2); + // OK, now abs(op1) > abs(op2). + } + if op1.sign == 1 { + mem::swap(&mut op1.sign, &mut op2.sign); + 1 + } else { + // So we know the sign, and op1 > 0. + 0 + } + } else if op1.sign == 1 { + op1.sign = 0; + op2.sign = 0; + 1 + } else { + 0 + }; + // Now, op1 > abs(op2) > 0. + + let int = if op2.sign == 0 { + op1.int + op2.int + } else { + op1.int - op2.int + }; + fix::fix(Decimal::from_triple(sign, int, op1.exp), rounding) +} + +/// `a - b` — port of `_pydecimal.__sub__` (1198-1212): computed as +/// `a + (-b)` after the NaN check (so an sNaN in `b` raises before the +/// negation copies it). +fn sub(a: &Decimal, b: &Decimal, rounding: RoundMode) -> RunResult { + if (a.is_special() || b.is_special()) + && let Some(nan) = check_nans(a, Some(b))? + { + return Ok(nan); + } + add(a, &b.copy_negate(), rounding) +} + +/// `a * b` — port of `_pydecimal.__mul__` (1229-1275). `(±)INF * 0` (either +/// order) raises `InvalidOperation`; the coefficient-`1` shortcuts keep +/// `x * 10**k` from multiplying coefficients. The general product is bounded +/// by its inputs (two ≤ [`super::DECIMAL_MAX_DIGITS`]+1-digit coefficients), +/// so no tracker check is needed before the multiply. +fn mul(a: &Decimal, b: &Decimal, rounding: RoundMode) -> RunResult { + let sign = a.sign ^ b.sign; + + if a.is_special() || b.is_special() { + if let Some(nan) = check_nans(a, Some(b))? { + return Ok(nan); + } + if a.is_infinite() { + return if b.is_zero() { + Err(ExcType::decimal_invalid_operation()) // (+-)INF * 0 + } else { + Ok(Decimal::infinity(sign)) + }; + } + if b.is_infinite() { + return if a.is_zero() { + Err(ExcType::decimal_invalid_operation()) // 0 * (+-)INF + } else { + Ok(Decimal::infinity(sign)) + }; + } + } + + let exp = a.exp + b.exp; + + // Special case for multiplying by zero. + if a.is_zero() || b.is_zero() { + // Fixing in case the exponent is out of bounds. + return fix::fix(Decimal::from_triple(sign, BigInt::ZERO, exp), rounding); + } + // Special case for multiplying by a power of 10. + if a.coeff.is_one() { + return fix::fix(Decimal::from_triple(sign, b.coeff.clone(), exp), rounding); + } + if b.coeff.is_one() { + return fix::fix(Decimal::from_triple(sign, a.coeff.clone(), exp), rounding); + } + + fix::fix(Decimal::from_triple(sign, &a.coeff * &b.coeff, exp), rounding) +} + +/// `a / b` — port of `_pydecimal.__truediv__` (1277-1334). +/// +/// The `10**shift` alignment is bounded: `shift = len(b) - len(a) + prec + 1`, +/// so when non-negative it is at most `b`'s digit count + 29, and `-shift` is +/// at most `a`'s digit count (digit counts capped at +/// [`super::DECIMAL_MAX_DIGITS`] + 1) — never an attacker-scaled exponent. +/// +/// The `coeff % 5 == 0 → coeff += 1` step is CPython's inexactness marker: +/// an inexact quotient ending in `0` or `5` is nudged off the rounding +/// boundary so the later `fix` (which only sees `prec + 1` digits) rounds it +/// exactly as the infinitely precise quotient would. +fn true_div(a: &Decimal, b: &Decimal, rounding: RoundMode) -> RunResult { + let sign = a.sign ^ b.sign; + + if a.is_special() || b.is_special() { + if let Some(nan) = check_nans(a, Some(b))? { + return Ok(nan); + } + if a.is_infinite() && b.is_infinite() { + return Err(ExcType::decimal_invalid_operation()); // (+-)INF/(+-)INF + } + if a.is_infinite() { + return Ok(Decimal::infinity(sign)); + } + if b.is_infinite() { + // Division by infinity: Clamped is untrapped, and the zero-at-Etiny + // result is returned *without* `fix`, exactly as CPython does. + return Ok(Decimal::from_triple(sign, BigInt::ZERO, ETINY)); + } + } + + // Special cases for zeroes. + if b.is_zero() { + return Err(if a.is_zero() { + ExcType::decimal_division_undefined() // 0 / 0 + } else { + ExcType::decimal_division_by_zero() // x / 0 + }); + } + + let (coeff, exp) = if a.is_zero() { + (BigInt::ZERO, a.exp - b.exp) + } else { + // OK, so neither = 0, INF or NaN. + let shift = digit_len(b) - digit_len(a) + PREC + 1; + let mut exp = a.exp - b.exp - shift; + let (mut coeff, remainder) = if shift >= 0 { + (&a.coeff * pow10_bounded(shift.unsigned_abs())).div_rem(&b.coeff) + } else { + a.coeff.div_rem(&(&b.coeff * pow10_bounded(shift.unsigned_abs()))) + }; + if remainder.is_zero() { + // Result is exact; get as close to the ideal exponent as possible. + let ideal_exp = a.exp - b.exp; + let ten = BigInt::from(10u8); + while exp < ideal_exp && (&coeff % &ten).is_zero() { + coeff /= &ten; + exp += 1; + } + } else if (&coeff % BigInt::from(5u8)).is_zero() { + // Result is not exact; adjust to ensure correct rounding. + coeff += BigInt::one(); + } + (coeff, exp) + }; + + fix::fix(Decimal::from_triple(sign, coeff, exp), rounding) +} + +/// `(a // b, a % b)` to `prec` precision — port of `_pydecimal._divide` +/// (1336-1367). Assumes neither operand is a NaN, `a` is not infinite and +/// `b` is nonzero (it may be infinite: quotient `0`, remainder `a`). +/// +/// The `10**|op1.exp - op2.exp|` alignment is bounded because it only runs +/// when `-2 < expdiff <= prec`: the exponent gap then equals +/// `expdiff + len(b) - len(a)`, at most `prec` plus the operands' digit +/// counts (≤ [`super::DECIMAL_MAX_DIGITS`] + 1 each). The early-exit +/// rescale's zero-pad is bounded by `len(b)` for the same reason +/// (`expdiff <= -2` gives `a.exp - b.exp <= len(b) - len(a) - 2`). Raises +/// `InvalidOperation [DivisionImpossible]` when the integer quotient would +/// need more than `prec` digits — CPython refuses to materialise a quotient +/// wider than the working precision (`Decimal('1e40') // 3` raises rather +/// than rounding). +fn divide(a: &Decimal, b: &Decimal, rounding: RoundMode) -> RunResult<(Decimal, Decimal)> { + let sign = a.sign ^ b.sign; + let ideal_exp = if b.is_infinite() { a.exp } else { a.exp.min(b.exp) }; + + let expdiff = a.adjusted() - b.adjusted(); + if a.is_zero() || b.is_infinite() || expdiff <= -2 { + return Ok(( + Decimal::from_triple(sign, BigInt::ZERO, 0), + fix::rescale(a, ideal_exp, rounding)?, + )); + } + if expdiff <= PREC { + let mut op1 = WorkRep::new(a); + let mut op2 = WorkRep::new(b); + if op1.exp >= op2.exp { + let gap = u64::try_from(op1.exp - op2.exp).expect("gap bounded by expdiff <= prec (see doc)"); + op1.int *= pow10_bounded(gap); + } else { + let gap = u64::try_from(op2.exp - op1.exp).expect("gap bounded by expdiff > -2 (see doc)"); + op2.int *= pow10_bounded(gap); + } + let (q, r) = op1.int.div_rem(&op2.int); + let prec_limit = pow10_bounded(u64::try_from(DEFAULT_PREC).expect("prec fits u64")); + if q < prec_limit { + return Ok(( + Decimal::from_triple(sign, q, 0), + Decimal::from_triple(a.sign, r, ideal_exp), + )); + } + } + + // Here the quotient is too large to be representable. + Err(ExcType::decimal_division_impossible()) +} + +/// `a % b` — port of `_pydecimal.__mod__` (1420-1444): the remainder half of +/// [`divide`], `fix`ed. `INF % x` and `x % 0` (nonzero `x`) raise +/// `InvalidOperation` (*not* `DivisionByZero` — only `//` and `/` signal +/// that); `0 % 0` is `DivisionUndefined`. +fn modulo(a: &Decimal, b: &Decimal, rounding: RoundMode) -> RunResult { + if let Some(nan) = check_nans(a, Some(b))? { + return Ok(nan); + } + if a.is_infinite() { + Err(ExcType::decimal_invalid_operation()) // INF % x + } else if b.is_zero() { + Err(if a.is_zero() { + ExcType::decimal_division_undefined() // 0 % 0 + } else { + ExcType::decimal_invalid_operation() // x % 0 + }) + } else { + let (_, remainder) = divide(a, b, rounding)?; + fix::fix(remainder, rounding) + } +} + +/// `a // b` — port of `_pydecimal.__floordiv__` (1518-1540): the quotient +/// half of [`divide`], returned *without* a further `fix` (it is already an +/// at-most-`prec`-digit integer at exponent 0), exactly as CPython does. +/// `INF // INF` raises; `INF // x` is a signed infinity; `x // 0` raises +/// `DivisionByZero` and `0 // 0` `DivisionUndefined`. +fn floor_div(a: &Decimal, b: &Decimal, rounding: RoundMode) -> RunResult { + if let Some(nan) = check_nans(a, Some(b))? { + return Ok(nan); + } + if a.is_infinite() { + if b.is_infinite() { + Err(ExcType::decimal_invalid_operation()) // INF // INF + } else { + Ok(Decimal::infinity(a.sign ^ b.sign)) + } + } else if b.is_zero() { + Err(if a.is_zero() { + ExcType::decimal_division_undefined() // 0 // 0 + } else { + ExcType::decimal_division_by_zero() // x // 0 + }) + } else { + Ok(divide(a, b, rounding)?.0) + } +} + +/// `_pydecimal._WorkRep` (5597-5621): the mutable `(sign, int, exp)` working +/// form of a finite `Decimal` used by the add/div kernels — the coefficient +/// as a `BigInt` rather than a digit string, so alignment shifts are integer +/// multiplies. +struct WorkRep { + sign: u8, + int: BigInt, + exp: i64, +} + +impl WorkRep { + /// Extracts the working representation of a finite `Decimal`. + fn new(d: &Decimal) -> Self { + Self { + sign: d.sign, + int: d.coeff.clone(), + exp: d.exp, + } + } + + /// `len(str(w.int))` as the `i64` the exponent arithmetic works in. + fn digits(&self) -> i64 { + magnitude_digits(&self.int) + } +} + +/// `_pydecimal._normalize` (5623-5649): aligns two working reps to a common +/// exponent (and comparable coefficient length) so addition can operate on +/// the raw coefficients. +/// +/// The `min(-1, tmp_len - prec - 2)` clamp is the padding guard: adding +/// `10**exp` (with `exp = tmp.exp + min(-1, tmp_len - prec - 2)`) to `tmp` +/// rounds identically to adding any smaller positive quantity, so an `other` +/// below that threshold is *replaced* by the sentinel `1·10**exp`. This +/// bounds the `10**(tmp.exp - other.exp)` pad applied to `tmp` at +/// `max(1, prec + 2 - tmp_len) + other_len - 1` digits — at most `prec + 1` +/// plus `other`'s digit count (≤ [`super::DECIMAL_MAX_DIGITS`] + 1) — so a +/// pair like `1E+999999 + 1E-999999` cannot demand a two-million-digit pad. +fn normalize_ops(op1: WorkRep, op2: WorkRep) -> (WorkRep, WorkRep) { + let (mut tmp, mut other, swapped) = if op1.exp < op2.exp { + (op2, op1, true) + } else { + (op1, op2, false) + }; + + let tmp_len = tmp.digits(); + let other_len = other.digits(); + let exp = tmp.exp + (-1i64).min(tmp_len - PREC - 2); + if other_len + other.exp - 1 < exp { + other.int = BigInt::one(); + other.exp = exp; + } + let pad = u64::try_from(tmp.exp - other.exp).expect("tmp has the larger exponent, clamped (see doc)"); + tmp.int *= pow10_bounded(pad); + tmp.exp = other.exp; + + // Hand the reps back in argument order (CPython mutates through aliases). + if swapped { (other, tmp) } else { (tmp, other) } +} + +/// `len(self._int)` as the `i64` the exponent arithmetic works in (`1` for a +/// zero coefficient, like CPython's `'0'` string). +fn digit_len(d: &Decimal) -> i64 { + i64::try_from(d.digits()).expect("digit count fits i64") +} diff --git a/crates/monty/src/types/decimal/cmp.rs b/crates/monty/src/types/decimal/cmp.rs new file mode 100644 index 000000000..6ca548c22 --- /dev/null +++ b/crates/monty/src/types/decimal/cmp.rs @@ -0,0 +1,390 @@ +//! `Decimal` comparisons and hashing: the `_pydecimal._cmp` core (770-815), +//! the `_convert_for_comparison` numeric-tower dispatch (6015-6049), exact +//! `Decimal` ↔ `int` comparison at any magnitude, `float`/`int` conversion +//! helpers, and Monty's cross-type-consistent hash. +//! +//! NaN policy (from `_pydecimal`'s rich-comparison notes): `==`/`!=` treat a +//! quiet NaN as unequal/unordered but raise `InvalidOperation` for an sNaN; +//! `<`/`<=`/`>`/`>=` raise `InvalidOperation` for *any* NaN +//! (`_compare_check_nans`, 730-761) — the trap is always armed under the +//! fixed context. + +use std::cmp::Ordering; + +use num_bigint::{BigInt, Sign}; +use num_integer::Integer; +use num_traits::Zero; + +use super::{Decimal, methods, parse, pow10_bounded}; +use crate::{ + exception_private::{ExcType, RunResult}, + hash::{HashValue, hash_python_long_int}, + heap::{Heap, HeapData}, + resource::{ResourceTracker, check_pow_size}, + value::Value, +}; + +/// Ordering comparison (`<`, `<=`, `>`, `>=`) of a `Decimal` against another +/// `Value`, with CPython's NaN behaviour (`_compare_check_nans`, 730-761): an +/// ordering involving *any* NaN — quiet or signaling, on either side (a float +/// NaN converts to a quiet NaN first) — raises `InvalidOperation`, unlike +/// `==`/`!=` which return `False`/`True` for a quiet NaN. +/// +/// Returns `Ok(None)` when `other` is not a number `Decimal` compares with; +/// the caller maps that to `CmpOrder::Incomparable`, which the VM's ordering +/// machinery raises as CPython's `TypeError: '<' not supported …`. `reversed` +/// flips the result for the operand order `other < Decimal`. +pub(crate) fn cmp_value( + d: &Decimal, + other: &Value, + heap: &Heap, + reversed: bool, +) -> RunResult> { + match as_cmp_operand(other, heap) { + None => Ok(None), + Some(operand) if d.is_nan() || operand_is_nan(&operand) => Err(ExcType::decimal_invalid_operation()), + Some(operand) => { + let ordering = cmp_operand(d, operand); + Ok(Some(if reversed { ordering.reverse() } else { ordering })) + } + } +} + +/// Equality of a `Decimal` against another `Value` — the port of +/// `_pydecimal.__eq__` (837-843) over `_convert_for_comparison` (6015-6049). +/// +/// `Ok(None)` (`NotImplemented`) for any non-number so `Value::py_eq` can try +/// the reflected comparison and finally fall back to unequal — matching how +/// `int`/`float` report cross-type equality. An sNaN on either side raises +/// `InvalidOperation` (`__eq__` goes through `_check_nans`, whose trap is +/// always armed here); a quiet NaN compares unequal to everything, itself +/// included. `int`s of any width compare exactly with no digit cap (see +/// [`cmp_decimal_int`]), and a `float` compares via its exact binary +/// expansion (`Decimal('0.1') != 0.1` but `Decimal('0.5') == 0.5`). +pub(super) fn eq_value(d: &Decimal, other: &Value, heap: &Heap) -> RunResult> { + match as_cmp_operand(other, heap) { + None => Ok(None), + Some(operand) if d.is_snan() || matches!(operand, CmpOperand::Dec(o) if o.is_snan()) => { + Err(ExcType::decimal_invalid_operation()) + } + Some(operand) if d.is_nan() || operand_is_nan(&operand) => Ok(Some(false)), + Some(operand) => Ok(Some(cmp_operand(d, operand) == Ordering::Equal)), + } +} + +/// A numeric operand that a `Decimal` can be compared against, extracted from +/// a `Value`. These are exactly the types CPython lets `Decimal` compare with +/// (`int`, `bool`, `float`, and another `Decimal`); everything else is not +/// comparable. `Copy` (an `i64`, an `f64`, or a borrow) so it can be passed +/// by value out of [`as_cmp_operand`]. +#[derive(Clone, Copy)] +pub(super) enum CmpOperand<'a> { + Int(i64), + Big(&'a BigInt), + Float(f64), + Dec(&'a Decimal), +} + +/// Resolves a `Value` to a comparable numeric operand, or `None` if `Decimal` +/// does not compare with it (so `Decimal('1') == 'x'` is `False`, not an +/// error). +pub(super) fn as_cmp_operand<'a>(value: &'a Value, heap: &'a Heap) -> Option> { + match value { + Value::Bool(b) => Some(CmpOperand::Int(i64::from(*b))), + Value::Int(i) => Some(CmpOperand::Int(*i)), + Value::Float(f) => Some(CmpOperand::Float(*f)), + Value::Ref(id) => match heap.get(*id) { + HeapData::LongInt(li) => Some(CmpOperand::Big(li.inner())), + HeapData::Decimal(d) => Some(CmpOperand::Dec(d)), + _ => None, + }, + _ => None, + } +} + +/// Whether the comparison operand is a NaN of any kind. A float NaN counts: +/// CPython's `_convert_for_comparison` converts it via `Decimal.from_float`, +/// yielding a quiet NaN. +fn operand_is_nan(operand: &CmpOperand<'_>) -> bool { + match operand { + CmpOperand::Float(f) => f.is_nan(), + CmpOperand::Dec(d) => d.is_nan(), + CmpOperand::Int(_) | CmpOperand::Big(_) => false, + } +} + +/// Dispatches a NaN-free comparison to the right core — the moral equivalent +/// of `_pydecimal._convert_for_comparison` (6015-6049), except that `int`s go +/// through [`cmp_decimal_int`] (exact at any magnitude, no `Decimal` +/// conversion and hence no digit cap) and a `float` through its exact +/// [`parse::from_float`] binary expansion. +fn cmp_operand(d: &Decimal, operand: CmpOperand<'_>) -> Ordering { + match operand { + CmpOperand::Int(i) => cmp_decimal_int(d, &BigInt::from(i)), + CmpOperand::Big(n) => cmp_decimal_int(d, n), + CmpOperand::Float(f) => cmp_decimal(d, &parse::from_float(f)), + CmpOperand::Dec(other) => cmp_decimal(d, other), + } +} + +/// Compares two non-NaN decimals — port of `_pydecimal._cmp` (770-815): +/// infinity/zero/sign shortcuts, then the `adjusted()` comparison, and only +/// when the adjusted exponents tie the zero-padded coefficient-string +/// comparison. +/// +/// The padding is bounded: equal adjusted exponents mean +/// `len_a + exp_a == len_b + exp_b`, so each pad equals the digit-count +/// difference — at most [`super::DECIMAL_MAX_DIGITS`] + 1 — and differing +/// adjusted exponents short-circuit without padding at all. +fn cmp_decimal(a: &Decimal, b: &Decimal) -> Ordering { + debug_assert!(!a.is_nan() && !b.is_nan(), "callers filter NaNs"); + if a.is_special() || b.is_special() { + // Only infinities remain among specials: compare the ∞-codes + // (-1 / 0 / 1 — equal codes, including inf vs same-signed inf, tie). + return a.infinity_sign().cmp(&b.infinity_sign()); + } + + // Check for zeros; Decimal('0') == Decimal('-0'). + if a.is_zero() { + return if b.is_zero() { + Ordering::Equal + } else if b.sign == 0 { + Ordering::Less + } else { + Ordering::Greater + }; + } + if b.is_zero() { + return if a.sign == 0 { Ordering::Greater } else { Ordering::Less }; + } + + // If different signs, the negative one is less. + if b.sign < a.sign { + return Ordering::Less; + } + if a.sign < b.sign { + return Ordering::Greater; + } + + // Same nonzero sign: compare magnitudes, flipping for negatives. + let a_adjusted = a.adjusted(); + let b_adjusted = b.adjusted(); + let magnitude = if a_adjusted == b_adjusted { + // CPython pads the higher-exponent coefficient string with zeros + // (`self._int + '0'*(self._exp - other._exp)`); scaling the + // coefficient by the same power of ten compares identically without + // building strings. Equal adjusted exponents mean the pad equals the + // digit-count difference, ≤ [`super::DECIMAL_MAX_DIGITS`] + 1. + match a.exp.cmp(&b.exp) { + Ordering::Equal => a.coeff.cmp(&b.coeff), + Ordering::Greater => { + let pad = u64::try_from(a.exp - b.exp).expect("positive difference"); + (&a.coeff * pow10_bounded(pad)).cmp(&b.coeff) + } + Ordering::Less => { + let pad = u64::try_from(b.exp - a.exp).expect("positive difference"); + a.coeff.cmp(&(&b.coeff * pow10_bounded(pad))) + } + } + } else { + a_adjusted.cmp(&b_adjusted) + }; + if a.sign == 1 { magnitude.reverse() } else { magnitude } +} + +/// Compares a non-NaN `Decimal` against an integer exactly and at any +/// magnitude by comparing the integer part as a `BigInt` — so neither a huge +/// `int` nor a huge-exponent `Decimal` needs to convert to the other's type +/// (and the constructor digit cap never applies to comparisons). +/// +/// A cheap sign/magnitude pre-check ([`cmp_decimal_int_by_magnitude`]) +/// settles most pairs without materialising the integer part; the exact +/// [`integer_part_with_fraction`] build only runs when the magnitudes +/// genuinely overlap. +fn cmp_decimal_int(d: &Decimal, n: &BigInt) -> Ordering { + if d.is_infinite() { + if d.sign == 1 { Ordering::Less } else { Ordering::Greater } + } else if let Some(ordering) = cmp_decimal_int_by_magnitude(d, n) { + ordering + } else { + // Magnitudes overlap: materialise the integer part for an exact + // compare. + let (int_part, has_fraction) = integer_part_with_fraction(d); + match int_part.cmp(n) { + // Integer parts equal: d's fractional part breaks the tie — a + // positive d with a fraction exceeds its truncation, a negative + // one falls below it. + Ordering::Equal if has_fraction => { + if d.sign == 1 { + Ordering::Less + } else { + Ordering::Greater + } + } + ordering => ordering, + } + } +} + +/// Cheaply resolves [`cmp_decimal_int`] from sign and integer-digit count +/// alone, returning `None` only when the magnitudes overlap and an exact +/// comparison is required. This is the DoS guard: it lets a tiny +/// `Decimal('1E+999999')` be compared against an `int` without ever +/// materialising the ~million-digit integer part, which would otherwise burn +/// CPU and memory on every comparison. `d` must be finite (NaN/∞ are handled +/// by the caller). +/// +/// The `int`'s digit count comes from `n.bits()` (`digits ≈ bits · log10 2`, +/// computed in u128 with an 11-decimal-place under-approximation of +/// `log10 2`, so the true count is `estimate` or `estimate + 1` for any int +/// below ~10^11 bits); the exact-but-O(len²) `to_string` fallback only runs +/// when the estimate window actually straddles `d`'s digit count. +fn cmp_decimal_int_by_magnitude(d: &Decimal, n: &BigInt) -> Option { + let d_sign: i8 = if d.is_zero() { + 0 + } else if d.sign == 1 { + -1 + } else { + 1 + }; + let n_sign: i8 = match n.sign() { + Sign::Minus => -1, + Sign::NoSign => 0, + Sign::Plus => 1, + }; + if d_sign != n_sign { + // Opposite signs (or one operand is zero): the larger sign is the + // larger value, so comparing the signs themselves settles it. + Some(d_sign.cmp(&n_sign)) + } else if d_sign == 0 { + Some(Ordering::Equal) + } else { + // Same nonzero sign: a k-digit integer lies in `[10^(k-1), 10^k)`, so + // a differing integer-digit count settles the magnitude outright. + // `adjusted` is cheap (coefficient length + exponent); `|d| < 1` has + // zero integer digits and is thus smaller in magnitude than any + // nonzero int. + let int_digits_d = match d.adjusted() { + adj if adj >= 0 => u64::try_from(adj).expect("non-negative adjusted fits u64") + 1, + _ => 0, + }; + let estimate = u64::try_from(u128::from(n.bits()) * 30_102_999_566 / 100_000_000_000) + .expect("digit estimate of an in-memory int fits u64"); + let n_digits = if int_digits_d < estimate || int_digits_d > estimate + 1 { + // The `[estimate, estimate + 1]` window misses `d`'s count, so + // the estimate is exact enough to order by. + estimate + } else { + u64::try_from(n.magnitude().to_string().len()).expect("digit count fits u64") + }; + match int_digits_d.cmp(&n_digits) { + // Equal digit counts → magnitudes overlap; caller compares + // exactly. + Ordering::Equal => None, + // For negatives, the larger magnitude is the smaller value. + magnitude => Some(if d_sign < 0 { magnitude.reverse() } else { magnitude }), + } + } +} + +/// The signed integer part of a finite decimal (truncated toward zero) plus +/// whether any nonzero fractional digits were discarded — the exact-compare +/// step of [`cmp_decimal_int`]. +/// +/// Bounded: only called when the operands' integer-digit counts match, so a +/// positive `exp` is at most the `int`'s (already materialised) digit count, +/// and a negative one is below the coefficient's digit count +/// (≤ [`super::DECIMAL_MAX_DIGITS`] + 1). +fn integer_part_with_fraction(d: &Decimal) -> (BigInt, bool) { + let (magnitude, has_fraction) = if d.exp >= 0 { + let exp = u64::try_from(d.exp).expect("non-negative exponent fits u64"); + (&d.coeff * pow10_bounded(exp), false) + } else { + let (quotient, remainder) = d.coeff.div_rem(&pow10_bounded(d.exp.unsigned_abs())); + (quotient, !remainder.is_zero()) + }; + (if d.sign == 1 { -magnitude } else { magnitude }, has_fraction) +} + +/// Computes the Monty-consistent hash of a `Decimal` so equal numbers hash +/// equally across types (`hash(Decimal(5)) == hash(5)`, +/// `hash(Decimal('1.5')) == hash(1.5)`). +/// +/// This mirrors **Monty's** runtime hash scheme, NOT CPython's +/// `_PyHASH_MODULUS`: integral values hash as the integer (via +/// [`hash_python_long_int`], which matches both `int` and `LongInt`); +/// non-integral and infinite values hash as the `f64` bit pattern (matching +/// Monty's `float` hash); a quiet NaN hashes as `f64::NAN`'s bits and never +/// raises, while a signaling NaN raises CPython's exact +/// `TypeError: Cannot hash a signaling NaN value`. +pub(super) fn hash_decimal(d: &Decimal, tracker: &impl ResourceTracker) -> RunResult { + if d.is_snan() { + Err(ExcType::decimal_snan_hash()) + } else if d.is_qnan() { + Ok(HashValue::new(f64::NAN.to_bits())) + } else if d.is_infinite() { + let inf = if d.sign == 1 { f64::NEG_INFINITY } else { f64::INFINITY }; + Ok(HashValue::new(inf.to_bits())) + } else if is_integral(d) { + Ok(hash_python_long_int(&integral_to_bigint(d, tracker)?)) + } else { + // Finite non-integral: hash as the nearest f64's bits, exactly like a + // `float` value (the specials are pre-checked above, so + // `methods::to_float` cannot fail here). + Ok(HashValue::new(methods::to_float(d)?.to_bits())) + } +} + +/// Whether a finite decimal's value is an exact integer: a non-negative +/// exponent, a zero coefficient, or every dropped digit zero (`1.00` is +/// integral, `1.5` is not). +/// +/// The divisibility check is bounded: it only runs when the fractional digit +/// count is below the coefficient's digit count +/// (≤ [`super::DECIMAL_MAX_DIGITS`] + 1); more fractional digits than +/// coefficient digits means a nonzero magnitude below `1`, never integral. +fn is_integral(d: &Decimal) -> bool { + if d.exp >= 0 || d.coeff_is_zero() { + true + } else { + let frac_digits = d.exp.unsigned_abs(); + frac_digits < digits_u64(d) && (&d.coeff % pow10_bounded(frac_digits)).is_zero() + } +} + +/// The exact integer value of an integral finite `Decimal` as a `BigInt` +/// (truncating toward zero for a non-integral input — `int(Decimal('1.5'))` +/// is `1`). Shared by hashing (to match Python's `int` hash) and the +/// `int()`/`round()` conversions in `methods.rs`. +/// +/// The `10**exp` factor for a positive exponent derives from the value's +/// (constructor-bounded, up to ~10^18) exponent, so it is pre-checked with +/// [`check_pow_size`] (base 10 has 4 significant bits): +/// `int(Decimal('1E+999999999999'))` raises a `ResourceError` under limits +/// instead of attempting an unbounded allocation. The negative-exponent side +/// divides by `10**k` with `k` capped at the coefficient's digit count (a +/// larger `k` truncates to zero outright, without materialising the power). +pub(super) fn integral_to_bigint(d: &Decimal, tracker: &impl ResourceTracker) -> RunResult { + let magnitude = if d.coeff_is_zero() { + // Any zero — even one at a huge exponent — is exactly 0; never + // materialise its 10**exp scale factor. + BigInt::ZERO + } else if d.exp >= 0 { + let exp = u64::try_from(d.exp).expect("non-negative exponent fits u64"); + check_pow_size(4, exp, tracker)?; + &d.coeff * pow10_bounded(exp) + } else { + let frac_digits = d.exp.unsigned_abs(); + if frac_digits >= digits_u64(d) { + BigInt::ZERO // |d| < 1 truncates to zero + } else { + &d.coeff / pow10_bounded(frac_digits) + } + }; + Ok(if d.sign == 1 { -magnitude } else { magnitude }) +} + +/// The coefficient digit count as a `u64` (`1` for a zero coefficient). +fn digits_u64(d: &Decimal) -> u64 { + u64::try_from(d.digits()).expect("digit count fits u64") +} diff --git a/crates/monty/src/types/decimal/fix.rs b/crates/monty/src/types/decimal/fix.rs new file mode 100644 index 000000000..b5c2d1060 --- /dev/null +++ b/crates/monty/src/types/decimal/fix.rs @@ -0,0 +1,284 @@ +//! The rounding kernel: `_pydecimal._fix` (result finalisation under the +//! fixed context), the eight `ROUND_*` decision functions, `_rescale`, +//! `_round`, and per-call `rounding=` resolution. +//! +//! Everything here follows `_pydecimal` line-for-line, operating on the +//! coefficient's digit string exactly as CPython does (post-`fix` coefficients +//! are ≤ 28 digits and unfixed constructor operands are ≤ +//! [`DECIMAL_MAX_DIGITS`], so the per-call stringify is cheap). Under the +//! fixed context only the `Overflow` signal can trap; the ignored-signal calls +//! (`Inexact`/`Rounded`/`Subnormal`/`Underflow`/`Clamped`) are omitted. + +use std::iter::repeat_n; + +use num_bigint::BigInt; +use num_traits::Zero; + +use super::{DECIMAL_MAX_DIGITS, Decimal, EMAX, ETINY, ETOP, PREC, ROUNDING_MODES, RoundMode}; +use crate::exception_private::{ExcType, RunError, RunResult, SimpleException}; + +/// `_pydecimal._fix`: rounds `d` to the working precision and clamps its +/// exponent into range — the finalisation step of every arithmetic result. +/// The only trapped outcome is `Overflow`; underflow quietly rounds toward +/// [`ETINY`] (down to a signed zero when nothing survives). +/// +/// `rounding` is a parameter (not always `HalfEven`) because exp/pow force +/// `ROUND_HALF_EVEN` while quantize/`__round__` thread the per-call mode into +/// their own rescale before `fix` — mirror CPython if a mutable context is +/// ever reintroduced. +pub(super) fn fix(d: Decimal, rounding: RoundMode) -> RunResult { + if d.is_special() { + return Ok(if d.is_nan() { fix_nan(d) } else { d }); + } + + // A zero only has its exponent clamped into `[Etiny, Emax]` (clamp=0). + if d.coeff_is_zero() { + let new_exp = d.exp.clamp(ETINY, EMAX); + return Ok(if new_exp == d.exp { + d + } else { + Decimal::from_triple(d.sign, BigInt::ZERO, new_exp) + }); + } + + let len = i64::try_from(d.digits()).expect("digit count fits i64"); + // The smallest allowable exponent of the result: rounding to `prec` + // digits puts the last kept digit at this exponent. + let exp_min = len + d.exp - PREC; + if exp_min > ETOP { + // Overflow: `exp_min > Etop` iff `d.adjusted() > Emax`. + return Err(ExcType::decimal_overflow()); + } + // A subnormal result rounds at Etiny instead (Underflow/Subnormal are + // untrapped, so subnormality itself needs no bookkeeping here). + let mut exp_min = exp_min.max(ETINY); + + if d.exp < exp_min { + // Too many digits: round at the cut. A value smaller than the least + // representable magnitude is replaced by the sentinel `1E(exp_min-1)` + // so the decision functions see "everything below the cut". + let int_str = if len + d.exp - exp_min < 0 { + "1".to_owned() + } else { + d.coeff_str() + }; + let digits = usize::try_from((len + d.exp - exp_min).max(0)).expect("non-negative cut fits usize"); + let changed = round_decision(&int_str, digits, d.sign, rounding); + let mut coeff = if digits == 0 { + "0".to_owned() + } else { + int_str[..digits].to_owned() + }; + if changed > 0 { + // Increment the kept digits; a carry past `prec` digits sheds the + // surplus trailing zero into the exponent. + coeff = (parse_digits(&coeff) + 1u8).to_string(); + if coeff.len() > super::DEFAULT_PREC { + coeff.pop(); + exp_min += 1; + } + } + if exp_min > ETOP { + // The rounding carry pushed the exponent out of range. + return Err(ExcType::decimal_overflow()); + } + return Ok(Decimal::from_triple(d.sign, parse_digits(&coeff), exp_min)); + } + + // Representable to begin with (the clamp=1 fold-down branch of CPython's + // `_fix` is dead code under the fixed clamp=0 and is deliberately not + // ported — it would pad the coefficient by up to ~Emax digits). + Ok(d) +} + +/// `_pydecimal._fix_nan`: decapitates a NaN payload to the working precision +/// (keeps the *last* `prec` digits, leading zeros stripped by the re-parse). +pub(super) fn fix_nan(d: Decimal) -> Decimal { + let payload = d.coeff_str(); + if !d.coeff_is_zero() && payload.len() > super::DEFAULT_PREC { + let kept = parse_digits(&payload[payload.len() - super::DEFAULT_PREC..]); + Decimal { coeff: kept, ..d } + } else { + d + } +} + +/// `_pydecimal._rescale`: returns `d` with exponent `exp`, padding with zeros +/// or rounding under `rounding`. Specials pass through unchanged; the +/// operation is quiet (no signals) and context-free. +/// +/// The zero-padding length is defensively bounded: every caller's own checks +/// (quantize's digit bounds, `to_integral`'s `exp >= 0` short-circuit) keep it +/// tiny, but a future caller must not be able to turn this into an unbounded +/// allocation. +pub(super) fn rescale(d: &Decimal, exp: i64, rounding: RoundMode) -> RunResult { + if d.is_special() { + return Ok(d.clone()); + } + if d.coeff_is_zero() { + return Ok(Decimal::from_triple(d.sign, BigInt::ZERO, exp)); + } + + if d.exp >= exp { + // Pad with zeros: coeff · 10^(d.exp − exp). + let pad = usize::try_from(d.exp - exp).map_err(|_| rescale_pad_error())?; + if d.digits() + pad > DECIMAL_MAX_DIGITS { + return Err(rescale_pad_error()); + } + let mut coeff = d.coeff_str(); + coeff.extend(repeat_n('0', pad)); + return Ok(Decimal::from_triple(d.sign, parse_digits(&coeff), exp)); + } + + // Too many digits; round and lose data. If `d.adjusted() < exp - 1`, + // replace `d` by the sentinel `1E(exp-1)` before rounding. + let len = i64::try_from(d.digits()).expect("digit count fits i64"); + let (int_str, digits) = if len + d.exp - exp < 0 { + ("1".to_owned(), 0) + } else { + ( + d.coeff_str(), + usize::try_from(len + d.exp - exp).expect("non-negative cut fits usize"), + ) + }; + let changed = round_decision(&int_str, digits, d.sign, rounding); + let coeff = if digits == 0 { "0" } else { &int_str[..digits] }; + let coeff = if changed == 1 { + parse_digits(coeff) + 1u8 + } else { + parse_digits(coeff) + }; + Ok(Decimal::from_triple(d.sign, coeff, exp)) +} + +/// The guard error for an out-of-bounds `rescale` pad. Unreachable through the +/// current callers (each pre-checks); an internal error rather than a Python +/// exception because reaching it means a caller lost its bound. +fn rescale_pad_error() -> RunError { + RunError::internal("decimal rescale pad out of bounds") +} + +/// `_pydecimal._round`: rounds a nonzero, non-special `d` to `places` +/// significant figures (quiet, context-free). `places >= 1`. +pub(super) fn round_sig(d: &Decimal, places: usize, rounding: RoundMode) -> RunResult { + debug_assert!(places >= 1, "_round requires places >= 1"); + if d.is_special() || d.coeff_is_zero() { + return Ok(d.clone()); + } + let places_i = i64::try_from(places).expect("places fits i64"); + let ans = rescale(d, d.adjusted() + 1 - places_i, rounding)?; + // The rescale's carry can grow the adjusted exponent (99.97 → 100.0 at 3 + // sig figs), leaving an extra trailing zero; a second rescale sheds it. + if ans.adjusted() == d.adjusted() { + Ok(ans) + } else { + rescale(&ans, ans.adjusted() + 1 - places_i, rounding) + } +} + +/// The eight `ROUND_*` decision functions, keyed by `mode`, exactly as +/// `_pydecimal` defines them. `int_str` is the coefficient digit string of a +/// finite nonzero value, `cut` the number of digits kept (`0 <= cut < +/// int_str.len()`). Returns `1` (round away from zero), `0` (dropped digits +/// are all zero — exact), or `-1` (nonzero digits dropped, truncate). +pub(super) fn round_decision(int_str: &str, cut: usize, sign: u8, mode: RoundMode) -> i8 { + let digits = int_str.as_bytes(); + let round_down = || if all_zeros(digits, cut) { 0i8 } else { -1 }; + let round_half_up = || { + if matches!(digits[cut], b'5'..=b'9') { + 1i8 + } else if all_zeros(digits, cut) { + 0 + } else { + -1 + } + }; + match mode { + RoundMode::Down => round_down(), + RoundMode::Up => -round_down(), + RoundMode::HalfUp => round_half_up(), + RoundMode::HalfDown => { + if exact_half(digits, cut) { + -1 + } else { + round_half_up() + } + } + RoundMode::HalfEven => { + if exact_half(digits, cut) && (cut == 0 || matches!(digits[cut - 1], b'0' | b'2' | b'4' | b'6' | b'8')) { + -1 + } else { + round_half_up() + } + } + RoundMode::Ceiling => { + if sign == 1 { + round_down() + } else { + -round_down() + } + } + RoundMode::Floor => { + if sign == 0 { + round_down() + } else { + -round_down() + } + } + RoundMode::Zero05Up => { + if cut > 0 && !matches!(digits[cut - 1], b'0' | b'5') { + round_down() + } else { + -round_down() + } + } + } +} + +/// `_pydecimal._all_zeros`: whether every digit from `cut` on is `'0'`. +fn all_zeros(digits: &[u8], cut: usize) -> bool { + digits[cut..].iter().all(|&b| b == b'0') +} + +/// `_pydecimal._exact_half`: whether the digits from `cut` are exactly +/// `5000…0`. +fn exact_half(digits: &[u8], cut: usize) -> bool { + digits[cut] == b'5' && all_zeros(digits, cut + 1) +} + +/// Parses an ASCII digit string (a coefficient slice) back into a `BigInt`. +fn parse_digits(s: &str) -> BigInt { + BigInt::parse_bytes(s.as_bytes(), 10).expect("coefficient slice is ASCII digits") +} + +/// Resolves a rounding-mode string (`"ROUND_HALF_UP"`, …) to its +/// [`RoundMode`] via the shared [`ROUNDING_MODES`] table, so the set of valid +/// modes lives in exactly one place. `None` for any other string. +pub(super) fn rounding_mode_from_str(s: &str) -> Option { + ROUNDING_MODES + .into_iter() + .find(|(name, _)| <&str>::from(*name) == s) + .map(|(_, mode)| mode) +} + +/// CPython's `TypeError` for an invalid per-call `rounding=` value (any +/// non-mode string *or* non-string raises the same message). +pub(super) fn invalid_rounding_error() -> RunError { + SimpleException::new_msg( + ExcType::TypeError, + "valid values for rounding are:\n [ROUND_CEILING, ROUND_FLOOR, ROUND_UP, ROUND_DOWN,\n \ + ROUND_HALF_UP, ROUND_HALF_DOWN, ROUND_HALF_EVEN,\n ROUND_05UP]" + .to_owned(), + ) + .into() +} + +impl Decimal { + /// Whether the coefficient itself is zero — distinct from + /// [`Decimal::is_zero`], which is false for specials: `fix`/`rescale` use + /// this on values already known finite, and NaN payloads reuse it as + /// "payload is empty". + pub(super) fn coeff_is_zero(&self) -> bool { + self.coeff.is_zero() + } +} diff --git a/crates/monty/src/types/decimal/format.rs b/crates/monty/src/types/decimal/format.rs new file mode 100644 index 000000000..651bf0f17 --- /dev/null +++ b/crates/monty/src/types/decimal/format.rs @@ -0,0 +1,422 @@ +//! `Decimal` string rendering: [`write_decimal`], the GDA *to-scientific- +//! string* conversion behind `str`/`repr`/snapshots/the host boundary, and +//! [`format_decimal`], the `Decimal.__format__` port driving f-strings and +//! `format()`. +//! +//! Both render from the value's **native digit string** (never an `f64` +//! round-trip, never `normalize`d), so significant trailing zeros and +//! exponent identity survive exactly as CPython requires +//! (`f'{Decimal("1.10"):.20f}' == '1.10000000000000000000'`, +//! `str(Decimal('1E+5')) == '1E+5'`). + +use std::fmt; + +use super::{Decimal, EMAX, PREC, RoundMode, Special, canonical_string, fix}; +use crate::{ + exception_private::{ExcType, RunError, RunResult, SimpleException}, + fstring::{ParsedFormatSpec, TypeChar, numeric_sign, pad_signed_numeric}, + resource::{ResourceError, ResourceTracker}, + string_builder::StringBuilder, +}; + +/// Writes the Python canonical string form of `d` into `f` — the +/// *to-scientific-string* conversion from the General Decimal Arithmetic +/// specification, exactly as CPython's `Decimal.__str__` implements it. +/// +/// This is the single source of truth for `str`, `repr` (wrapped in +/// `Decimal('…')`), the snapshot / host-boundary canonical string, and the +/// empty-format-spec fast path. `capitals` selects the exponent marker `E` +/// (true — CPython's default `capitals=1`) vs `e`. +/// +/// Specials use CPython's exact spellings, sign included: `Infinity` / +/// `-Infinity`, `NaN` / `-NaN`, `sNaN` / `-sNaN`, with a NaN payload's digits +/// appended (`sNaN123`) and an empty payload printing none (`Decimal('NaN0')` +/// is just `NaN` — the parser strips a zero payload to empty). +pub(crate) fn write_decimal(d: &Decimal, capitals: bool, f: &mut impl fmt::Write) -> fmt::Result { + let sign = if d.sign == 1 { "-" } else { "" }; + match d.special { + Special::Inf => write!(f, "{sign}Infinity"), + Special::Qnan | Special::Snan => { + let word = if d.special == Special::Snan { "sNaN" } else { "NaN" }; + write!(f, "{sign}{word}")?; + // A zero coefficient means "no payload": bare `NaN`, never `NaN0`. + if !d.coeff_is_zero() { + write!(f, "{}", d.coeff)?; + } + Ok(()) + } + Special::Finite => write_finite(d, sign, capitals, f), + } +} + +/// The finite branch of [`write_decimal`]: the coefficient digits placed +/// around the decimal point per the GDA `dotplace` rule, with an explicit +/// `E±exp` marker whenever the point moved. Every zero run synthesised here +/// is tiny by construction: fewer than 6 on the left (the `leftdigits > -6` +/// bound) and none on the right (plain notation requires `exp <= 0`). +fn write_finite(d: &Decimal, sign: &str, capitals: bool, f: &mut impl fmt::Write) -> fmt::Result { + // Coefficient digits (`"0"` for a zero — the spec's coefficient string is + // a single `"0"`) and the decimal exponent. Exponents are bounded by the + // constructor's literal bounds (~±2e18) and digit counts by the sandbox + // cap, so the i64 position arithmetic below cannot overflow. + let int_str = d.coeff_str(); + let len_int = i64::try_from(int_str.len()).expect("digit count fits i64"); + // `leftdigits` is the position of the decimal point measured from the + // start of the coefficient (CPython's `self._exp + len(self._int)`). + let leftdigits = d.exp + len_int; + // Plain notation when the exponent is non-positive and the point is not + // too far left; scientific (one digit before the point) otherwise. + let dotplace = if d.exp <= 0 && leftdigits > -6 { leftdigits } else { 1 }; + + if dotplace <= 0 { + // 0.<-dotplace zeros> + write!(f, "{sign}0.")?; + for _ in 0..-dotplace { + f.write_char('0')?; + } + f.write_str(&int_str)?; + } else if dotplace >= len_int { + // , no fractional part + write!(f, "{sign}{int_str}")?; + for _ in 0..dotplace - len_int { + f.write_char('0')?; + } + } else { + // Split the coefficient around the point (ASCII digits, byte- + // indexable); `dotplace` is in `1..len_int` here, so the conversion + // never fails. + let dp = usize::try_from(dotplace).expect("dotplace > 0 in this branch"); + write!(f, "{sign}{}.{}", &int_str[..dp], &int_str[dp..])?; + } + + if leftdigits != dotplace { + let marker = if capitals { 'E' } else { 'e' }; + write!(f, "{marker}{:+}", leftdigits - dotplace)?; + } + Ok(()) +} + +/// Formats a `Decimal` against a parsed format-spec, faithfully mirroring +/// CPython's `Decimal.__format__`: the value's native digit string drives the +/// output, so significant trailing zeros survive exactly as CPython requires +/// (`f'{Decimal("1.20"):g}' == '1.20'`). Sign placement, grouping, padding +/// and alignment are layered on by the shared [`pad_signed_numeric`]. +/// +/// The spec is validated up front — every combination CPython rejects (an +/// integer/string presentation code like `d`/`x`/`s`, or a grouping option +/// with `n`) raises the single [`ExcType::decimal_invalid_format_string`] +/// message, *before* the special-value shortcut, because CPython parses (and +/// rejects) the spec even for infinities and NaNs. +/// +/// All rounding is `ROUND_HALF_EVEN`: CPython's `__format__` rounds under the +/// context's `rounding`, and Monty's fixed context pins it to the default +/// (`f'{Decimal("2.675"):.2f}'` is `'2.68'`). +pub(crate) fn format_decimal( + d: &Decimal, + spec: &ParsedFormatSpec, + tracker: &impl ResourceTracker, +) -> RunResult { + let presentation = resolve_presentation(spec)?; + + // The sign applies to specials too: `format(Decimal('-NaN'), '10')` is + // `' -NaN'` (CPython's `_format_sign` runs before the special word). + let is_negative = d.is_signed(); + let magnitude = d.copy_abs(); + + if !magnitude.is_finite() { + // Special values render as their canonical word; only `%` appends a + // suffix. CPython ignores the `0` *flag* for specials — + // `format(Decimal('inf'), '010')` is `' Infinity'`, space-filled — + // but honors an *explicit* fill/alignment (`0>10`, `0=10`, `*>10`). + // The spec parser sets `zero_pad` only for the flag form (an explicit + // align keeps it false), so when it is set the promoted `fill = '0'` + // must be reset alongside it, or the default right-align path would + // still pad with zeros. + let mut word = canonical_string(&magnitude); + if presentation.percent { + word.push('%'); + } + let mut special_spec = spec.clone(); + if special_spec.zero_pad { + special_spec.zero_pad = false; + special_spec.fill = ' '; + special_spec.align = None; + } + let sign = numeric_sign(is_negative, &word, &special_spec); + return Ok(pad_signed_numeric(sign, "", &word, &special_spec)); + } + + let abs_str = if spec.type_char.is_none() && spec.precision.is_none() && !spec.alternate { + // An empty spec equals CPython's default `G` presentation, which is in + // turn identical to the canonical (`str`) form — same trailing-zero + // and exponent rules — so reuse it directly. + canonical_string(&magnitude) + } else { + render_decimal_body(&magnitude, &presentation, spec, tracker)? + }; + Ok(pad_signed_numeric( + numeric_sign(is_negative, &abs_str, spec), + "", + &abs_str, + spec, + )) +} + +/// Where the decimal point sits relative to the coefficient's digits, per +/// CPython's `Decimal.__format__` (`dotplace` logic). `Fixed` never shows an +/// exponent, `Scientific` always does, `General` chooses between them. +enum Placement { + Fixed, + Scientific, + General, +} + +/// A `Decimal` presentation resolved from a spec's type char: the point +/// placement, whether the exponent marker / default form is uppercase, and +/// whether the `%` suffix (and ×100 pre-scale) applies. +struct DecimalPresentation { + placement: Placement, + uppercase: bool, + percent: bool, +} + +/// Resolves a format spec to a [`DecimalPresentation`], rejecting every spec +/// CPython's `Decimal.__format__` refuses. An empty (type-less) spec defaults +/// to the uppercase `G` CPython uses when `capitals` is set (its default). +fn resolve_presentation(spec: &ParsedFormatSpec) -> RunResult { + // `n` does its own locale grouping, so an explicit grouping option with + // `n` is rejected; the integer/string codes have no `Decimal` formatter at + // all. + if spec.grouping.is_some() && spec.type_char == Some(TypeChar::N) { + return Err(ExcType::decimal_invalid_format_string()); + } + let (placement, uppercase, percent) = match spec.type_char { + None => (Placement::General, true, false), + // `f`/`F` differ only for the non-finite word, which is always cased + // the same, so they share the finite renderer. + Some(TypeChar::F | TypeChar::FUpper) => (Placement::Fixed, false, false), + Some(TypeChar::E) => (Placement::Scientific, false, false), + Some(TypeChar::EUpper) => (Placement::Scientific, true, false), + Some(TypeChar::G | TypeChar::N) => (Placement::General, false, false), + Some(TypeChar::GUpper) => (Placement::General, true, false), + Some(TypeChar::Percent) => (Placement::Fixed, false, true), + Some(_) => return Err(ExcType::decimal_invalid_format_string()), + }; + Ok(DecimalPresentation { + placement, + uppercase, + percent, + }) +} + +/// Cap on the zeros the fixed-point presentations (`f`/`F`/`%`) synthesise +/// from a value's *exponent* (`format(Decimal('1E+999999'), 'f')` writes +/// 999999 trailing zeros into an untracked Rust `String`). Post-`fix` values +/// have `Etiny <= exp <= Emax`, so anything a fixed-context computation can +/// produce fits (`+ 2` covers the `%` pre-scale); only raw constructor +/// literals, whose exponents reach ~±2e18, exceed it. CPython attempts the +/// multi-exabyte string and dies with `MemoryError`; Monty raises +/// [`fixed_pad_limit_error`] before allocating (see `limitations/decimal.md`). +const FIXED_PAD_LIMIT: i64 = EMAX + PREC + 2; + +/// The Monty-specific `ValueError` for a fixed-point rendering whose +/// exponent-driven zero padding exceeds [`FIXED_PAD_LIMIT`]. Only reachable +/// for unfixed constructor literals with `|exp| > Emax`-ish magnitudes. +fn fixed_pad_limit_error() -> RunError { + SimpleException::new_msg( + ExcType::ValueError, + "decimal exponent out of range for fixed-point formatting".to_owned(), + ) + .into() +} + +/// Renders the unsigned body of a finite, non-negative `magnitude` for the +/// resolved `presentation`, following CPython's `__format__` / +/// `_format_number`: round per the type, place the decimal point +/// (`dotplace`), then split the coefficient digit string into integer / +/// fraction parts and emit `intpart[.fracpart][e±exp][%]`. +fn render_decimal_body( + magnitude: &Decimal, + presentation: &DecimalPresentation, + spec: &ParsedFormatSpec, + tracker: &impl ResourceTracker, +) -> RunResult { + let precision = spec.precision; + // `%` multiplies by 100 by raising the exponent — coefficient preserved, + // so `0.5 → 50`, not `50.0`. Literal exponents are ≤ ~2e18: no overflow. + let value = if presentation.percent { + Decimal { + exp: magnitude.exp + 2, + ..magnitude.clone() + } + } else { + magnitude.clone() + }; + + // Round per presentation under the fixed context's ROUND_HALF_EVEN. + // Rounding here only ever *drops* digits — zero-padding up to a requested + // precision happens on the digit string below, so an attacker-chosen + // precision can't inflate the value itself (CPython's `_round`/`_rescale` + // pad the coefficient instead; the rendered output is identical, and the + // string padding is bounded by the resource guard in `format_with_spec`). + let rounded = match presentation.placement { + // `e` with precision `p` keeps one digit before the point and `p` + // after it: `p + 1` significant digits (CPython: `_round(precision+1)`). + Placement::Scientific => match precision { + Some(p) => round_significant(value, p.saturating_add(1))?, + None => value, + }, + Placement::Fixed => match precision { + Some(p) => round_fraction(value, p)?, + None => value, + }, + // `g` with a precision rounds to that many significant digits (≥ 1 — + // CPython converts a `.0` precision to 1 for the g family), but only + // when the value actually has more; otherwise its own digits + // (trailing zeros included) are kept. + Placement::General => match precision { + Some(p) => round_significant(value, p.max(1))?, + None => value, + }, + }; + + // A zero with a positive exponent has no fixed-point form; CPython + // rescales it to `0E0` for the fixed presentations, so + // `format(Decimal('0'), '%')` is `'0%'`, not `'000%'`. + let zero_pos_exp_fixed = rounded.is_zero() && matches!(presentation.placement, Placement::Fixed) && rounded.exp > 0; + let (coeff, exp) = if zero_pos_exp_fixed { + ("0".to_owned(), 0) + } else { + (rounded.coeff_str(), rounded.exp) + }; + let coeff_len = i64::try_from(coeff.len()).expect("coefficient digit count fits i64"); + // CPython's `leftdigits = self._exp + len(self._int)`. + let leftdigits = exp + coeff_len; + + let dotplace = match presentation.placement { + // Scientific puts one digit before the point; a zero with an explicit + // precision shifts the exponent by that precision (CPython: + // `1 - precision`, so `format(Decimal('0'), '.3e')` is `0.000e+3`). + Placement::Scientific if rounded.is_zero() => match precision { + Some(p) => 1 - i64::try_from(p).unwrap_or(i64::MAX), + None => 1, + }, + Placement::Scientific => 1, + Placement::Fixed => leftdigits, + // General is fixed when the value has no positive exponent and isn't + // tiny, else scientific — CPython's `self._exp <= 0 and leftdigits > -6`. + Placement::General if exp <= 0 && leftdigits > -6 => leftdigits, + Placement::General => 1, + }; + + // Only the fixed presentations can synthesise exponent-many zeros (the + // general form is bounded by its `leftdigits > -6` / `exp <= 0` gate and + // scientific pads at most `precision`, which the tracker already vetted); + // cap them so a wild constructor literal can't OOM the host. + if matches!(presentation.placement, Placement::Fixed) + && (-dotplace > FIXED_PAD_LIMIT || dotplace - coeff_len > FIXED_PAD_LIMIT) + { + return Err(fixed_pad_limit_error()); + } + + let out_exp = leftdigits - dotplace; + + // Emit `intpart[.fracpart][e±exp][%]` directly into a tracker-reserved + // builder (the `StringBuilder` rule): the exponent-driven zero pads can + // reach `FIXED_PAD_LIMIT` (~1 MB) and the precision pad is + // attacker-sized, so both must be charged as they grow rather than + // assembled on the untracked Rust heap. The pads fit `usize` on every + // target: exponent-driven pads are capped by `FIXED_PAD_LIMIT` above, and + // precision-driven ones are bounded by the spec's `usize` precision. + let mut builder = StringBuilder::new(tracker); + + // Integer part: the coefficient digits left of `dotplace` (`"0"` when the + // point is at or before the first digit), zero-padded up to the point + // when it falls beyond the last digit. + let int_split = if dotplace <= 0 { + 0 + } else { + usize::try_from(dotplace) + .expect("positive dotplace fits usize") + .min(coeff.len()) + }; + if int_split == 0 { + builder.push('0')?; + } else { + builder.push_str(&coeff[..int_split])?; + } + if dotplace > coeff_len { + push_zeros( + &mut builder, + usize::try_from(dotplace - coeff_len).expect("positive dotplace gap fits usize"), + )?; + } + + // Fraction: a leading zero pad when the point sits left of the first + // digit, the remaining coefficient digits, then zero-fill up to an + // explicit `f`/`e`/`%` precision (CPython's `_rescale`/`_round` guarantee + // the count; `g` does not pad). The alternate form (`#`) keeps a trailing + // point even with no fraction (`f'{Decimal("5"):#g}' == '5.'`). + let lead_pad = usize::try_from(-dotplace.min(0)).expect("negative dotplace fits usize"); + let base_frac_len = lead_pad + (coeff.len() - int_split); + let target_frac_len = match precision { + Some(p) if matches!(presentation.placement, Placement::Fixed | Placement::Scientific) => base_frac_len.max(p), + _ => base_frac_len, + }; + if target_frac_len > 0 || spec.alternate { + builder.push('.')?; + push_zeros(&mut builder, lead_pad)?; + builder.push_str(&coeff[int_split..])?; + push_zeros(&mut builder, target_frac_len - base_frac_len)?; + } + + // The exponent marker shows for scientific always, and for general only + // when the point actually moved (CPython: `exp != 0 or type in 'eE'`). + if out_exp != 0 || matches!(presentation.placement, Placement::Scientific) { + builder.push(if presentation.uppercase { 'E' } else { 'e' })?; + // CPython always shows an explicit exponent sign (`e+3`, `e-2`). + builder.push(if out_exp < 0 { '-' } else { '+' })?; + builder.push_str(&out_exp.unsigned_abs().to_string())?; + } + if presentation.percent { + builder.push('%')?; + } + builder.finish_raw() +} + +/// Appends `n` `'0'`s to a tracker-reserved builder. +fn push_zeros(builder: &mut StringBuilder<'_, impl ResourceTracker>, n: usize) -> Result<(), ResourceError> { + for _ in 0..n { + builder.push('0')?; + } + Ok(()) +} + +/// Rounds `value` to at most `places` significant digits (HALF_EVEN), leaving +/// it untouched when it already has no more — CPython's `_round` would *pad* +/// the coefficient up to `places` instead, which renders identically because +/// [`render_decimal_body`] pads the digit string to the requested precision +/// anyway (and coefficient padding could exceed the sandbox digit cap for an +/// attacker-sized precision). Callers guarantee `places >= 1`, as +/// `_pydecimal._round` requires. +fn round_significant(value: Decimal, places: usize) -> RunResult { + if !value.is_zero() && value.digits() > places { + fix::round_sig(&value, places, RoundMode::HalfEven) + } else { + Ok(value) + } +} + +/// Rounds `value` to at most `places` fractional digits (HALF_EVEN), never +/// padding up: `rescale` is only entered when digits are actually dropped +/// (`exp < -places` — it *would* pad the coefficient when the exponent +/// exceeds the target, which for a huge requested precision could blow the +/// sandbox digit cap). +fn round_fraction(value: Decimal, places: usize) -> RunResult { + let target = -i64::try_from(places).unwrap_or(i64::MAX); + if value.exp < target { + fix::rescale(&value, target, RoundMode::HalfEven) + } else { + Ok(value) + } +} diff --git a/crates/monty/src/types/decimal/methods.rs b/crates/monty/src/types/decimal/methods.rs new file mode 100644 index 000000000..e9259303f --- /dev/null +++ b/crates/monty/src/types/decimal/methods.rs @@ -0,0 +1,435 @@ +//! `Decimal` methods beyond arithmetic — `quantize`, `to_integral_value`, +//! `copy_sign`, `normalize`, `as_tuple` — plus the number-protocol +//! conversions (`int`/`float`/`round`/`floor`/`ceil`/`trunc`), each a port of +//! the corresponding `_pydecimal.py` method under the fixed context. +//! +//! Method operands follow the C module's `_convert_other(raiseit=True)` +//! semantics (`_pydecimal.py:5996-6013`): only `Decimal` and integers convert +//! implicitly; `float` and `str` — both accepted by the *constructor* — raise +//! `TypeError: conversion from {type} to Decimal is not supported`. + +use num_bigint::BigInt; +use num_traits::Pow; + +use super::{DEFAULT_PREC, Decimal, EMAX, ETINY, PREC, RoundMode, Special, allocate, check_nans, fix, parse}; +use crate::{ + args::{ArgValues, FromArgs}, + bytecode::{CallResult, VM}, + defer_drop, + exception_private::{ExcType, RunResult}, + heap::{DropWithHeap, HeapData}, + intern::StaticStrings, + resource::{ResourceTracker, check_pow_size}, + types::{LongInt, NamedTuple, allocate_tuple, str::allocate_string}, + value::{EitherStr, Value}, +}; + +/// Validates that a zero-argument `Decimal` method received no arguments, +/// dropping any that were passed (so reference counts stay balanced) and +/// raising the CPython `Decimal.() takes no arguments (N given)` +/// TypeError otherwise. The qualified name is built only on the error path, so +/// the common (correct) call costs nothing extra. +pub(super) fn check_no_args(args: ArgValues, attr: &EitherStr, vm: &mut VM<'_, impl ResourceTracker>) -> RunResult<()> { + match args { + ArgValues::Empty => Ok(()), + other => { + let count = other.count(); + other.drop_with_heap(vm.heap); + Err(ExcType::type_error_no_args( + &format!("Decimal.{}", attr.as_str(vm.interns)), + count, + )) + } + } +} + +/// `Decimal.normalize()` — strips trailing zeros and maps any zero to a +/// (sign-preserving) `0E0`; the port of `_pydecimal.py:2475-2498`. The result +/// is `_fix`ed first, so a constructor literal beyond the context range can +/// raise `Overflow` here (`Decimal('9.9E999999999').normalize()`). +pub(super) fn normalize(d: Decimal, vm: &mut VM<'_, impl ResourceTracker>) -> RunResult { + if d.is_special() + && let Some(nan) = check_nans(&d, None)? + { + return allocate(nan, vm); + } + let dup = fix::fix(d, RoundMode::HalfEven)?; + if dup.is_infinite() { + return allocate(dup, vm); + } + // `if not dup: return _dec_from_triple(dup._sign, '0', 0)` — a zero + // normalizes to exponent 0 whatever exponent it carried (`-0E5` → `-0`). + if dup.is_zero() { + return allocate(Decimal::from_triple(dup.sign, BigInt::ZERO, 0), vm); + } + // Strip trailing coefficient zeros into the exponent, but never past the + // largest representable exponent (`exp_max` is `Emax` under clamp=0, so + // `Decimal('10000000E999992').normalize()` stops at `1E+999999`). + let digits = dup.coeff_str(); + let bytes = digits.as_bytes(); + let mut end = bytes.len(); + let mut exp = dup.exp; + while bytes[end - 1] == b'0' && exp < EMAX { + exp += 1; + end -= 1; + } + let coeff = BigInt::parse_bytes(&bytes[..end], 10).expect("coefficient slice is ASCII digits"); + allocate(Decimal::from_triple(dup.sign, coeff, exp), vm) +} + +/// `Decimal.as_tuple()` — the `DecimalTuple(sign, digits, exponent)` named +/// tuple (`_pydecimal.py:922-927`): `sign` is `0`/`1`, `digits` a tuple of +/// coefficient digits, and `exponent` an `int` — or `'F'`/`'n'`/`'N'` for +/// ∞ / NaN / sNaN. An infinity's digits are `(0,)`; a NaN's are its payload +/// digits, `()` when there is no payload (`Decimal('NaN').as_tuple().digits`). +pub(super) fn as_tuple(d: &Decimal, vm: &mut VM<'_, impl ResourceTracker>) -> RunResult { + let sign = Value::Int(i64::from(d.sign)); + let (digits, exponent) = match d.special { + Special::Qnan => (payload_digit_values(d), allocate_string("n", vm.heap)?), + Special::Snan => (payload_digit_values(d), allocate_string("N", vm.heap)?), + Special::Inf => (vec![Value::Int(0)], allocate_string("F", vm.heap)?), + Special::Finite => (coeff_digit_values(d), Value::Int(d.exp)), + }; + let digits_tuple = allocate_tuple(digits.into(), vm.heap)?; + let field_names = vec![ + EitherStr::from(StaticStrings::DecimalSign), + EitherStr::from(StaticStrings::DecimalDigits), + EitherStr::from(StaticStrings::DecimalExponent), + ]; + let named = NamedTuple::new( + StaticStrings::DecimalTuple, + field_names, + vec![sign, digits_tuple, exponent], + ); + Ok(Value::Ref(vm.heap.allocate(HeapData::NamedTuple(named))?)) +} + +/// The coefficient's digits as `Value::Int`s, in order (`"0"` yields `[0]`). +fn coeff_digit_values(d: &Decimal) -> Vec { + d.coeff_str().bytes().map(|b| Value::Int(i64::from(b - b'0'))).collect() +} + +/// A NaN payload's digits as `Value::Int`s — empty when there is no payload +/// (CPython stores an empty `_int` string; here that's a zero coefficient). +fn payload_digit_values(d: &Decimal) -> Vec { + if d.coeff_is_zero() { + Vec::new() + } else { + coeff_digit_values(d) + } +} + +/// Arguments of `Decimal.quantize(exp, rounding=None)`. +/// +/// The C module's signature also has a trailing `context=None`; Monty has no +/// `Context` objects, so passing one (positionally or by name) is a +/// `TypeError` (see `limitations/decimal.md`). `c_error` reproduces the C +/// module's wording exactly: `Decimal(1).quantize()` raises `function missing +/// required argument 'exp' (pos 1)` and surplus positionals raise `function +/// takes at most N arguments (M given)`. +#[derive(FromArgs)] +#[from_args(name = "quantize", style = c)] +struct QuantizeArgs { + exp: Value, + #[from_args(default)] + rounding: Option, +} + +/// `Decimal.quantize(exp, rounding=None)` — returns `self` with the exponent +/// of `exp`, rounding under the per-call mode; the port of +/// `_pydecimal.py:2500-2559` (see [`quantize_core`]). +pub(super) fn quantize_method( + d: Decimal, + args: ArgValues, + vm: &mut VM<'_, impl ResourceTracker>, +) -> RunResult { + let QuantizeArgs { exp, rounding } = QuantizeArgs::from_args(args, vm)?; + defer_drop!(exp, vm); + // The rounding argument validates *before* the operand converts, matching + // the C module (`Decimal(1).quantize(2.5, rounding='X')` raises the + // rounding TypeError, not the float-conversion one). + let rounding = resolve_rounding_arg(rounding, vm)?; + let exp_target = operand_to_decimal(exp, vm)?; + let ans = quantize_core(d, &exp_target, rounding)?; + Ok(CallResult::Value(allocate(ans, vm)?)) +} + +/// Arguments of `Decimal.to_integral_value(rounding=None)`. As with +/// [`QuantizeArgs`], the C module's trailing `context` parameter is not +/// supported (see `limitations/decimal.md`). +#[derive(FromArgs)] +#[from_args(name = "to_integral_value", style = c)] +struct ToIntegralValueArgs { + #[from_args(default)] + rounding: Option, +} + +/// `Decimal.to_integral_value(rounding=None)` — rounds to the nearest integer +/// *quietly* (no `_fix`, no precision cap); the port of +/// `_pydecimal.py:2662-2679`. A value with `exp >= 0` is already integral and +/// passes through untouched, exponent identity included +/// (`Decimal('1E+30').to_integral_value() == Decimal('1E+30')`). +pub(super) fn to_integral_value_method( + d: Decimal, + args: ArgValues, + vm: &mut VM<'_, impl ResourceTracker>, +) -> RunResult { + let ToIntegralValueArgs { rounding } = ToIntegralValueArgs::from_args(args, vm)?; + let rounding = resolve_rounding_arg(rounding, vm)?; + let ans = if d.is_special() { + match check_nans(&d, None)? { + Some(nan) => nan, + // An infinity is returned unchanged (`Decimal(self)`). + None => d, + } + } else if d.exp >= 0 { + d + } else { + // `rescale(0)` on `exp < 0` only drops digits, so its internal + // pad guard is unreachable from here. + fix::rescale(&d, 0, rounding)? + }; + Ok(CallResult::Value(allocate(ans, vm)?)) +} + +/// `Decimal.copy_sign(other)` — `self`'s digits with `other`'s sign; the port +/// of `_pydecimal.py:2995-2999`. A *copy* operation: quiet even when either +/// side is an sNaN (`Decimal('sNaN123').copy_sign(-1)` is `-sNaN123`). The +/// operand converts with method (not constructor) semantics, so +/// `copy_sign(-2.5)` raises the float-conversion `TypeError`. +pub(super) fn copy_sign_method( + d: Decimal, + args: ArgValues, + vm: &mut VM<'_, impl ResourceTracker>, +) -> RunResult { + let CopySignArgs { other } = CopySignArgs::from_args(args, vm)?; + defer_drop!(other, vm); + let other = operand_to_decimal(other, vm)?; + let ans = Decimal { sign: other.sign, ..d }; + Ok(CallResult::Value(allocate(ans, vm)?)) +} + +/// Arguments of `Decimal.copy_sign(other)` — the C module's parser wording +/// (`function missing required argument 'other' (pos 1)`). +#[derive(FromArgs)] +#[from_args(name = "copy_sign", style = c)] +struct CopySignArgs { + other: Value, +} + +/// `int(Decimal)` / `Decimal.__trunc__` — truncation toward zero; the port of +/// `_pydecimal.py:1573-1586`. NaN (quiet *or* signaling) raises the CPython +/// `ValueError`, an infinity the `OverflowError`. +pub(crate) fn to_int(d: &Decimal, vm: &mut VM<'_, impl ResourceTracker>) -> RunResult { + if d.is_nan() { + // `int(Decimal('sNaN'))` is the same ValueError as a quiet NaN. + return Err(ExcType::decimal_nan_to_int()); + } + if d.is_infinite() { + return Err(ExcType::decimal_infinity_to_int()); + } + let magnitude = if d.exp >= 0 { + // `int(self._int) * 10**self._exp` — the exponent comes straight from + // the value (a constructor literal can carry exp up to ~1e18), so the + // `10**exp` materialisation is pre-checked against the tracker. + let exp = u64::try_from(d.exp).expect("non-negative exponent fits u64"); + check_pow_size(4, exp, vm.heap.tracker())?; + &d.coeff * BigInt::from(10u8).pow(exp) + } else { + // `int(self._int[:self._exp] or '0')` — CPython slices the digit + // *string*: truncation just drops the last `-exp` digits, so no + // `10**-exp` is ever materialised (exp can be as low as ~-2e18). + let digits = d.coeff_str(); + let keep = i64::try_from(digits.len()).expect("digit count fits i64") + d.exp; + match usize::try_from(keep) { + Ok(keep) if keep > 0 => { + BigInt::parse_bytes(&digits.as_bytes()[..keep], 10).expect("coefficient slice is ASCII digits") + } + _ => BigInt::ZERO, + } + }; + let signed = if d.sign == 1 { -magnitude } else { magnitude }; + Ok(LongInt::new(signed).into_value(vm.heap)?) +} + +/// `float(Decimal)` — CPython's `float(str(self))` (`_pydecimal.py:1563-1571`): +/// the nearest `f64`, overflowing to ±∞ and underflowing to (signed) zero +/// exactly as `float()` does. Signed NaNs keep their sign bit; +/// a *signaling* NaN raises CPython's `ValueError`. Consumed by the `float()` +/// constructor and every float-consuming `math` function. +pub(crate) fn to_float(d: &Decimal) -> RunResult { + match d.special { + Special::Snan => Err(ExcType::decimal_snan_to_float()), + Special::Qnan => Ok(f64::NAN.copysign(if d.sign == 1 { -1.0 } else { 1.0 })), + Special::Inf => Ok(if d.sign == 1 { f64::NEG_INFINITY } else { f64::INFINITY }), + Special::Finite => { + let s = format!("{}{}E{}", if d.sign == 1 { "-" } else { "" }, d.coeff_str(), d.exp); + // Rust's f64 parser accepts `E` for any exponent, + // saturating to ±∞ / ±0 — the same behaviour as CPython's + // `float(str)`, and never an error for this shape. + Ok(s.parse::().expect("E always parses as f64")) + } + } +} + +/// `round(Decimal)` (one-argument `__round__`, `_pydecimal.py:1831-1843`) — +/// rounds HALF_EVEN to an `int` (`round(Decimal('2.5')) == 2`). NaN raises the +/// `int()` ValueError, infinity the OverflowError (the C module reuses the +/// conversion messages, not `_pydecimal`'s "cannot round a NaN"). +#[expect( + clippy::needless_pass_by_value, + reason = "the round() builtin hands over its cloned Decimal" +)] +pub(crate) fn round_to_int(d: Decimal, vm: &mut VM<'_, impl ResourceTracker>) -> RunResult { + rescale_to_int(&d, RoundMode::HalfEven, vm) +} + +/// `round(Decimal, ndigits)` (two-argument `__round__`, +/// `_pydecimal.py:1826-1830`) — exactly `self.quantize(Decimal('1E-n'))` under +/// the context rounding, full quantize checks included: a quiet NaN passes +/// through, but an infinity — or a result wider than the working precision — +/// raises `InvalidOperation`. +pub(crate) fn round_with_digits(d: Decimal, ndigits: i64, vm: &mut VM<'_, impl ResourceTracker>) -> RunResult { + // `exp = -ndigits`; `i64::MIN` cannot be negated, but its target exponent + // (2^63) is far outside quantize's `[Etiny, Emax]` bound, so it raises the + // same `InvalidOperation` CPython produces for that call. + let exp = ndigits.checked_neg().ok_or_else(ExcType::decimal_invalid_operation)?; + let target = Decimal::from_triple(0, BigInt::from(1u8), exp); + let ans = quantize_core(d, &target, RoundMode::HalfEven)?; + allocate(ans, vm) +} + +/// `math.floor(Decimal)` / `Decimal.__floor__` (`_pydecimal.py:1845-1858`) — +/// the greatest integer `<= self`; specials raise the `int()` errors. +pub(crate) fn floor_to_int(d: &Decimal, vm: &mut VM<'_, impl ResourceTracker>) -> RunResult { + rescale_to_int(d, RoundMode::Floor, vm) +} + +/// `math.ceil(Decimal)` / `Decimal.__ceil__` (`_pydecimal.py:1860-1873`) — +/// the least integer `>= self`; specials raise the `int()` errors. +pub(crate) fn ceil_to_int(d: &Decimal, vm: &mut VM<'_, impl ResourceTracker>) -> RunResult { + rescale_to_int(d, RoundMode::Ceiling, vm) +} + +/// `math.trunc(Decimal)` / `Decimal.__trunc__` — an alias of `__int__` +/// (`_pydecimal.py:1588`). +pub(crate) fn trunc_to_int(d: &Decimal, vm: &mut VM<'_, impl ResourceTracker>) -> RunResult { + to_int(d, vm) +} + +/// Shared core of the one-argument `__round__`/`__floor__`/`__ceil__`: +/// `int(self._rescale(0, rounding))` with the specials raising the `int()` +/// conversion errors first (so `round(Decimal('sNaN'))` is the NaN +/// `ValueError`, *not* `InvalidOperation`). +fn rescale_to_int(d: &Decimal, rounding: RoundMode, vm: &mut VM<'_, impl ResourceTracker>) -> RunResult { + if d.is_nan() { + Err(ExcType::decimal_nan_to_int()) + } else if d.is_infinite() { + Err(ExcType::decimal_infinity_to_int()) + } else if d.exp >= 0 { + // Already integral: CPython's `_rescale(0)` would pad the coefficient + // with `exp` zeros only for `int()` to re-parse them; converting + // directly yields the same integer with the `10**exp` materialisation + // guarded by `to_int`'s `check_pow_size` instead of an unbounded pad. + to_int(d, vm) + } else { + // `exp < 0` only drops digits, so rescale's pad guard is unreachable. + to_int(&fix::rescale(d, 0, rounding)?, vm) + } +} + +/// The post-conversion body of `quantize` — `_pydecimal.py:2510-2559` with +/// the untrapped signal sites (`Subnormal`/`Inexact`/`Rounded`) omitted. +/// Shared by [`quantize_method`] and [`round_with_digits`], whose CPython +/// counterpart literally calls `quantize`, so the two surfaces cannot drift. +fn quantize_core(d: Decimal, exp_target: &Decimal, rounding: RoundMode) -> RunResult { + if d.is_special() || exp_target.is_special() { + if let Some(nan) = check_nans(&d, Some(exp_target))? { + return Ok(nan); + } + // Both infinite is a plain copy; one infinite is invalid. The C module + // raises its bare `[]` message here + // (not `_pydecimal`'s "quantize with one INF" wording). + if exp_target.is_infinite() || d.is_infinite() { + return if exp_target.is_infinite() && d.is_infinite() { + Ok(d) + } else { + Err(ExcType::decimal_invalid_operation()) + }; + } + } + + // The target exponent must lie within `[Etiny, Emax]`. + if !(ETINY..=EMAX).contains(&exp_target.exp) { + return Err(ExcType::decimal_invalid_operation()); + } + + // A zero self just adopts the target exponent (then `_fix` clamps). + if d.is_zero() { + return fix::fix(Decimal::from_triple(d.sign, BigInt::ZERO, exp_target.exp), rounding); + } + + // The result may neither exceed Emax nor need more than `prec` digits. + // The digit bound also caps `rescale`'s zero pad at `prec`, keeping its + // internal pad guard unreachable. + let self_adjusted = d.adjusted(); + if self_adjusted > EMAX { + return Err(ExcType::decimal_invalid_operation()); + } + if self_adjusted - exp_target.exp + 1 > PREC { + return Err(ExcType::decimal_invalid_operation()); + } + + let ans = fix::rescale(&d, exp_target.exp, rounding)?; + // Re-check after rescaling: a rounding carry can add a digit + // (`Decimal('9.999…995').quantize(Decimal('1e-27'))` raises). + if ans.adjusted() > EMAX { + return Err(ExcType::decimal_invalid_operation()); + } + if ans.digits() > DEFAULT_PREC { + return Err(ExcType::decimal_invalid_operation()); + } + + // The final `_fix` under the same per-call rounding — beyond exponent + // clamping it is a no-op here, kept for line-for-line fidelity. + fix::fix(ans, rounding) +} + +/// Resolves the per-call `rounding=` argument of `quantize` / +/// `to_integral_value`: absent or `None` selects the fixed context's +/// `ROUND_HALF_EVEN`; one of the eight `ROUND_*` strings selects that mode; +/// anything else — a non-mode string *or* a non-string — raises the C +/// module's single "valid values for rounding are: …" TypeError. +fn resolve_rounding_arg(rounding: Option, vm: &mut VM<'_, impl ResourceTracker>) -> RunResult { + let Some(value) = rounding else { + return Ok(RoundMode::HalfEven); + }; + if matches!(value, Value::None) { + return Ok(RoundMode::HalfEven); + } + let mode = match &value { + Value::InternString(id) => fix::rounding_mode_from_str(vm.interns.get_str(*id)), + Value::Ref(id) => match vm.heap.get(*id) { + HeapData::Str(s) => fix::rounding_mode_from_str(s.as_str()), + _ => None, + }, + _ => None, + }; + value.drop_with_heap(vm); + mode.ok_or_else(fix::invalid_rounding_error) +} + +/// Converts a method operand with the C module's `_convert_other(raiseit=True)` +/// semantics: `Decimal` passes through, integers (`bool` included) convert +/// exactly, and everything else — notably `float` and `str`, which the +/// *constructor* accepts — raises `TypeError: conversion from {type} to +/// Decimal is not supported`. +fn operand_to_decimal(value: &Value, vm: &VM<'_, impl ResourceTracker>) -> RunResult { + match value { + Value::Bool(_) | Value::Int(_) => parse::decimal_from_value(value, vm), + Value::Ref(id) if matches!(vm.heap.get(*id), HeapData::LongInt(_) | HeapData::Decimal(_)) => { + parse::decimal_from_value(value, vm) + } + other => Err(ExcType::decimal_unsupported_conversion(other.py_type_name(vm))), + } +} diff --git a/crates/monty/src/types/decimal/mod.rs b/crates/monty/src/types/decimal/mod.rs new file mode 100644 index 000000000..c43d64cc7 --- /dev/null +++ b/crates/monty/src/types/decimal/mod.rs @@ -0,0 +1,577 @@ +//! Python `decimal.Decimal` — a Rust port of CPython's `_pydecimal.py` +//! numeric core, running under a **fixed context**. +//! +//! The representation mirrors `_pydecimal` field-for-field so the algorithms +//! port line-for-line: a value is `(-1)**sign · coeff · 10**exp`, with +//! specials tagged by [`Special`] (CPython tags them in `_exp` as +//! `'F'`/`'n'`/`'N'`). Every arithmetic result is finalised by [`fix::fix`], +//! the port of `_pydecimal._fix`. +//! +//! **Fixed context.** Monty has no mutable `decimal.Context`; every operation +//! runs under CPython's *default* context, hard-coded here: `prec = 28` +//! ([`DEFAULT_PREC`]), rounding `ROUND_HALF_EVEN`, `Emax = 999999`, `Emin = +//! -999999`, `capitals = 1`, `clamp = 0`. Methods that accept a per-call +//! `rounding=` argument in CPython (`quantize`, `to_integral_value`) accept it +//! here too. See `limitations/decimal.md`. +//! +//! **Signals.** CPython's default traps are `{InvalidOperation, +//! DivisionByZero, Overflow}`, and Monty's trap set is frozen there, so the +//! `_raise_error` machinery collapses to a fixed mapping applied at each port +//! site: +//! +//! | `_pydecimal` signal | here | +//! |---|---| +//! | `ConversionSyntax` | `Err(ExcType::decimal_conversion_syntax())` | +//! | `InvalidOperation` (incl. sNaN operands) | `Err(ExcType::decimal_invalid_operation())` | +//! | `DivisionByZero` | `Err(ExcType::decimal_division_by_zero())` | +//! | `DivisionUndefined` / `DivisionImpossible` | their dedicated helpers | +//! | `Overflow` | `Err(ExcType::decimal_overflow())` | +//! | `Clamped`/`Inexact`/`Rounded`/`Subnormal`/`Underflow`/`FloatOperation` | untrapped → the call site is simply omitted | +//! +//! **Sandbox guards.** CPython's algorithms are bounded *given* a bounded +//! operand size, so the port adds explicit guards where CPython relies on +//! "memory is finite": +//! +//! 1. every constructor caps the coefficient (and NaN payload) at +//! [`DECIMAL_MAX_DIGITS`] digits and the exponent at the C module's literal +//! bounds (`parse.rs`); +//! 2. `10**k` materialisations whose `k` derives from a value's exponent +//! pre-check with `resource::check_pow_size` (`int(d)`, hashing, division +//! alignment, the pow/transcendental kernels); +//! 3. the `extra += 3` roundability-refinement loops and Newton iterations +//! poll `check_time` each pass and carry a hard iteration cap; +//! 4. [`fix::rescale`] defends its zero-padding length even though every +//! caller pre-checks. + +mod arith; +mod cmp; +mod fix; +mod format; +mod methods; +mod parse; +mod pow; +mod trans; + +use std::{borrow::Cow, fmt}; + +pub(crate) use arith::{BinOp, abs, binary_op_value, divmod, neg, pos}; +pub(crate) use cmp::cmp_value; +pub(crate) use format::{format_decimal, write_decimal}; +pub(crate) use methods::{ceil_to_int, floor_to_int, round_to_int, round_with_digits, to_float, to_int, trunc_to_int}; +use num_bigint::BigInt; +use num_traits::{Pow, Zero}; +pub(crate) use parse::init; +use pow::power_modulo; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; + +use crate::{ + args::ArgValues, + bytecode::{CallResult, VM}, + exception_private::{ExcType, RunError, RunResult}, + hash::HashValue, + heap::{DropWithHeap, HeapData, HeapId, HeapItem, HeapRead}, + intern::StaticStrings, + resource::{ResourceTracker, check_pow_size}, + types::{LazyHeapSet, PyTrait, Type, str::allocate_string}, + value::{EitherStr, Value}, +}; + +/// CPython's default `context.prec` — the working precision every arithmetic +/// result is rounded to. Fixed: Monty has no mutable context. +pub(crate) const DEFAULT_PREC: usize = 28; +/// [`DEFAULT_PREC`] as the `i64` the exponent arithmetic works in (kept as a +/// separate literal so neither direction needs a lossy cast). +const PREC: i64 = 28; +/// CPython's default `Emax` — the largest allowed adjusted exponent of an +/// arithmetic result. A result beyond it raises `decimal.Overflow`. +const EMAX: i64 = 999_999; +/// CPython's default `Emin` (`-Emax`). +const EMIN: i64 = -999_999; +/// `Etiny = Emin - prec + 1` — the smallest allowed result exponent; a result +/// below it is rounded (subnormally) up to this exponent, underflowing to a +/// signed zero when nothing survives. +const ETINY: i64 = EMIN - PREC + 1; +/// `Etop = Emax - prec + 1` — the largest allowed exponent of a full-precision +/// result coefficient. +const ETOP: i64 = EMAX - PREC + 1; + +/// Sandbox cap on a `Decimal` coefficient (and NaN payload) in decimal digits, +/// applied by every constructor — the same philosophy as +/// [`long_int::INT_MAX_STR_DIGITS`](crate::types::long_int). Post-[`fix::fix`] +/// values carry ≤ 28 digits, so the cap only bites *unfixed* constructor +/// operands, where it keeps every downstream algorithm's intermediate `BigInt`s +/// small; CPython accepts arbitrarily long literals (documented divergence). +pub(crate) const DECIMAL_MAX_DIGITS: usize = 4300; + +/// The C `decimal` module's largest literal exponent (`MAX_EMAX` on 64-bit +/// builds): a literal whose adjusted exponent exceeds this raises +/// `InvalidOperation` at construction, in CPython and here alike. +const MAX_LITERAL_EXP: i64 = 999_999_999_999_999_999; +/// The C module's smallest literal exponent (`MIN_ETINY = MIN_EMIN - +/// (MAX_PREC - 1)`). +const MIN_LITERAL_EXP: i64 = -1_999_999_999_999_999_997; + +/// The non-finite kinds a `Decimal` can carry. `Finite` covers zeros and +/// ordinary values; the NaN kinds keep their payload in [`Decimal::coeff`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Special { + Finite, + /// `±Infinity` — `coeff` is zero and `exp` is `0`. + Inf, + /// Quiet NaN (CPython `_exp == 'n'`) — `coeff` holds the payload digits + /// (zero coefficient ⇔ no payload, printed as bare `NaN`). + Qnan, + /// Signaling NaN (CPython `_exp == 'N'`) — any arithmetic use raises + /// `InvalidOperation`; hashing raises `TypeError`. + Snan, +} + +/// `decimal.Decimal` storage, mirroring `_pydecimal`'s `(_sign, _int, _exp, +/// _is_special)` so the ported algorithms track the original line-for-line. +/// +/// Invariants: `sign` is `0` or `1`; `coeff` is non-negative (≤ +/// [`DECIMAL_MAX_DIGITS`] digits — enforced at construction); for `Inf` the +/// coefficient is zero; for the NaN kinds `coeff` is the payload and `exp` is +/// `0`. A leaf heap type: no heap references, never GC-tracked. Not `Copy` +/// (the coefficient is a `BigInt`), so callers clone out of heap reads. +/// +/// The derived `PartialEq` is *structural* (`1.2 != 1.20`); Python equality +/// goes through [`cmp`], never this. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Decimal { + sign: u8, + coeff: BigInt, + exp: i64, + special: Special, +} + +impl Decimal { + /// `Decimal('0')`. + fn zero() -> Self { + Self::from_triple(0, BigInt::ZERO, 0) + } + + /// A finite value from raw parts — `_pydecimal._dec_from_triple`. The + /// coefficient must be non-negative and within [`DECIMAL_MAX_DIGITS`] + /// (constructors validate; arithmetic results are bounded by `fix`). + fn from_triple(sign: u8, coeff: BigInt, exp: i64) -> Self { + debug_assert!(sign <= 1, "sign is 0 or 1"); + debug_assert!(coeff.sign() != num_bigint::Sign::Minus, "coefficient is non-negative"); + Self { + sign, + coeff, + exp, + special: Special::Finite, + } + } + + /// A signed infinity. + fn infinity(sign: u8) -> Self { + Self { + sign, + coeff: BigInt::ZERO, + exp: 0, + special: Special::Inf, + } + } + + /// A quiet NaN with the given payload (`BigInt::ZERO` for none). + fn qnan(sign: u8, payload: BigInt) -> Self { + Self { + sign, + coeff: payload, + exp: 0, + special: Special::Qnan, + } + } + + /// A signaling NaN with the given payload. + fn snan(sign: u8, payload: BigInt) -> Self { + Self { + sign, + coeff: payload, + exp: 0, + special: Special::Snan, + } + } + + /// An exact small-integer value. + fn from_i64(i: i64) -> Self { + Self::from_triple(u8::from(i < 0), BigInt::from(i.unsigned_abs()), 0) + } + + fn is_special(&self) -> bool { + self.special != Special::Finite + } + + pub(crate) fn is_finite(&self) -> bool { + self.special == Special::Finite + } + + fn is_nan(&self) -> bool { + matches!(self.special, Special::Qnan | Special::Snan) + } + + fn is_qnan(&self) -> bool { + self.special == Special::Qnan + } + + fn is_snan(&self) -> bool { + self.special == Special::Snan + } + + fn is_infinite(&self) -> bool { + self.special == Special::Inf + } + + /// True only for a finite zero (`bool(Decimal('NaN'))` is `True`). + fn is_zero(&self) -> bool { + self.is_finite() && self.coeff.is_zero() + } + + /// Whether the sign bit is set (`-0` and `-NaN` included) — CPython's + /// `is_signed()`. + fn is_signed(&self) -> bool { + self.sign == 1 + } + + /// `-1` for `-Infinity`, `1` for `Infinity`, `0` otherwise — CPython's + /// `_isinfinity()`. + fn infinity_sign(&self) -> i8 { + match self.special { + Special::Inf => { + if self.sign == 1 { + -1 + } else { + 1 + } + } + _ => 0, + } + } + + /// The coefficient's decimal digit string — CPython's `_int` (`"0"` for a + /// zero; the payload digits for a NaN, where CPython uses `""` for an + /// empty payload — callers that care check [`Self::is_zero`] / payload + /// emptiness via `coeff.is_zero()` instead of the string). + fn coeff_str(&self) -> String { + self.coeff.to_string() + } + + /// Number of digits in the coefficient — CPython's `len(self._int)` + /// (`1` for a zero). + fn digits(&self) -> usize { + usize::try_from(magnitude_digits(&self.coeff)).expect("digit count fits usize") + } + + /// `Decimal.adjusted()` — the exponent of the most-significant digit + /// (`exp + digits − 1`); `0` for specials, matching CPython. + fn adjusted(&self) -> i64 { + if self.is_special() { + 0 + } else { + self.exp + i64::try_from(self.digits()).expect("digit count fits i64") - 1 + } + } + + /// `copy_abs()` — `|self|` without rounding or signalling (works on sNaN). + fn copy_abs(&self) -> Self { + Self { + sign: 0, + ..self.clone() + } + } + + /// `copy_negate()` — sign flipped without rounding or signalling. + fn copy_negate(&self) -> Self { + Self { + sign: self.sign ^ 1, + ..self.clone() + } + } +} + +/// The `_pydecimal._check_nans` port for the fixed context: an sNaN operand +/// raises `InvalidOperation` (the trap is always armed); otherwise a quiet-NaN +/// operand propagates as the result — payload decapitated exactly as +/// `_fix_nan` does — and `None` means "no NaN involved, continue". +fn check_nans(a: &Decimal, b: Option<&Decimal>) -> RunResult> { + if a.is_snan() || b.is_some_and(Decimal::is_snan) { + Err(ExcType::decimal_invalid_operation()) + } else if a.is_qnan() { + Ok(Some(fix::fix_nan(a.clone()))) + } else if let Some(b) = b + && b.is_qnan() + { + Ok(Some(fix::fix_nan(b.clone()))) + } else { + Ok(None) + } +} + +/// The canonical, lossless, parse-round-trippable string for `d` — the same +/// string used by `str()`, snapshots, and the host boundary. Shared so all +/// four surfaces agree byte-for-byte. +pub(crate) fn canonical_string(d: &Decimal) -> String { + let mut s = String::new(); + write_decimal(d, true, &mut s).expect("writing to a String is infallible"); + s +} + +/// Parses a host/wire canonical decimal string into a heap `Decimal` `Value`. +/// Returns `None` on an unparsable or guard-rejected string — untrusted +/// boundary input must validate. Routes through the same parser as in-sandbox +/// `Decimal(str)`, so the digit cap and exponent bounds apply identically. +pub(crate) fn value_from_canonical_string(s: &str, vm: &mut VM<'_, impl ResourceTracker>) -> Option { + let d = parse::parse_str(s).ok()?; + allocate(d, vm).ok() +} + +/// Truthiness of a canonical decimal string for the host boundary: only zero +/// is falsy. An unparsable string defaults to truthy. +#[must_use] +pub(crate) fn string_is_truthy(s: &str) -> bool { + parse::parse_str(s).map_or(true, |d| !d.is_zero()) +} + +/// Three-argument `pow(base, exp, mod)` with `Decimal` operands: when *any* of +/// the three is a `Decimal`, all must promote (int / `LongInt` / `Decimal`) and +/// the result is `power_modulo`. `Ok(None)` when no operand is a `Decimal`, or +/// when one is a `float` — CPython's `float.__pow__` then wins with the +/// integers-only TypeError the `pow()` builtin's fallthrough produces. Any +/// other unpromotable operand (`str`, `list`, …) raises CPython's +/// three-operand `unsupported operand type(s)` TypeError. +pub(crate) fn pow3( + base: &Value, + exp: &Value, + modulus: &Value, + vm: &mut VM<'_, impl ResourceTracker>, +) -> RunResult> { + let any_decimal = [base, exp, modulus] + .into_iter() + .any(|v| matches!(v, Value::Ref(id) if matches!(vm.heap.get(*id), HeapData::Decimal(_)))); + if !any_decimal { + return Ok(None); + } + let (Some(a), Some(b), Some(m)) = ( + arith::promote(base, vm.heap)?, + arith::promote(exp, vm.heap)?, + arith::promote(modulus, vm.heap)?, + ) else { + // A `float` operand reproduces CPython's slot order: `float.__pow__` + // raises the integers-only message before the ternary fallback fires. + return if [base, exp, modulus].into_iter().any(|v| matches!(v, Value::Float(_))) { + Ok(None) + } else { + Err(ExcType::pow3_type_error( + base.py_type_name(vm), + exp.py_type_name(vm), + modulus.py_type_name(vm), + )) + }; + }; + power_modulo(a, b, m, vm).map(Some) +} + +impl Serialize for Decimal { + fn serialize(&self, serializer: S) -> Result { + // The canonical string is lossless (sign, payload, trailing zeros and + // exponent identity all survive) and shared with the host boundary. + serializer.serialize_str(&canonical_string(self)) + } +} + +impl<'de> Deserialize<'de> for Decimal { + fn deserialize>(deserializer: D) -> Result { + let s = >::deserialize(deserializer)?; + // Snapshots are untrusted: re-parse through the same validating parser + // as `Decimal(str)`, so a corrupt or out-of-bounds coefficient is a + // serde error (surfaced as a rejected load), never a bad value. + parse::parse_str(&s).map_err(|_| de::Error::custom(format!("invalid decimal {s:?}"))) + } +} + +/// `10**k` as a `BigInt`, *unguarded*: only for exponents structurally +/// bounded by operand digit counts (≤ ~2·[`DECIMAL_MAX_DIGITS`] — each call +/// site documents its bound). Never pass an attacker-scaled exponent — +/// value-exponent-derived powers must use [`pow10`] instead so the tracker +/// vets the materialisation. +fn pow10_bounded(k: u64) -> BigInt { + BigInt::from(10u8).pow(k) +} + +/// `10**e` for `e >= 0` under the sandbox pre-check (guard 2 in the module +/// docs): every power of ten whose exponent derives from a value's exponent +/// field routes through here, so `check_pow_size` vets the materialisation +/// even where a ported CPython bound already caps it. +fn pow10(e: i64, tracker: &impl ResourceTracker) -> RunResult { + let magnitude = u64::try_from(e).map_err(|_| pow10_bound_error())?; + check_pow_size(4, magnitude, tracker)?; + let small = u32::try_from(magnitude).map_err(|_| pow10_bound_error())?; + Ok(BigInt::from(10).pow(small)) +} + +/// Guard error for a [`pow10`] exponent outside `0..=u32::MAX` — unreachable +/// through the current callers (each is bounded by a ported CPython check or +/// by `check_pow_size` under any real memory limit); an internal error rather +/// than a Python exception because reaching it means a caller lost its bound. +fn pow10_bound_error() -> RunError { + RunError::internal("decimal pow10 exponent out of bounds") +} + +/// Python's `len(str(abs(n)))` — the decimal digit count of `n`'s magnitude +/// (`1` for zero). The single home for the "stringify is cheap" safety +/// argument: every coefficient this sees is bounded by the constructor digit +/// cap (≤ [`DECIMAL_MAX_DIGITS`], ~2× for arithmetic intermediates) or by the +/// kernels' precision-derived sizes, so the transient string stays a few KB. +fn magnitude_digits(n: &BigInt) -> i64 { + i64::try_from(n.magnitude().to_string().len()).expect("digit count fits i64") +} + +/// Allocates a `Decimal` on the heap — the single finalize point for +/// construction and arithmetic results. +fn allocate(d: Decimal, vm: &mut VM<'_, impl ResourceTracker>) -> RunResult { + Ok(Value::Ref(vm.heap.allocate(HeapData::Decimal(d))?)) +} + +/// The eight CPython `ROUND_*` modes: each interned name paired with the +/// [`RoundMode`] it selects. Single source of truth for the set — the +/// `decimal` module registers each name as a `ROUND_*` string constant, and +/// per-call `rounding=` arguments resolve against the same table. +pub(crate) const ROUNDING_MODES: [(StaticStrings, RoundMode); 8] = [ + (StaticStrings::RoundCeiling, RoundMode::Ceiling), + (StaticStrings::RoundFloor, RoundMode::Floor), + (StaticStrings::RoundUp, RoundMode::Up), + (StaticStrings::RoundDown, RoundMode::Down), + (StaticStrings::RoundHalfUp, RoundMode::HalfUp), + (StaticStrings::RoundHalfDown, RoundMode::HalfDown), + (StaticStrings::RoundHalfEven, RoundMode::HalfEven), + (StaticStrings::Round05Up, RoundMode::Zero05Up), +]; + +/// The rounding rule applied when dropping digits. All eight CPython modes are +/// supported; the fixed context's default is [`RoundMode::HalfEven`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RoundMode { + Down, + Up, + Floor, + Ceiling, + HalfUp, + HalfDown, + HalfEven, + /// `ROUND_05UP` — round away from zero only if the digit left of the cut + /// is `0` or `5`, otherwise toward zero. + Zero05Up, +} + +impl HeapItem for Decimal { + fn py_estimate_size(&self) -> usize { + // The struct plus the coefficient's out-of-line limbs (mirrors + // `LongInt`'s accounting). + size_of::() + usize::try_from(self.coeff.bits().div_ceil(8)).unwrap_or(usize::MAX) + } + + fn py_dec_ref_ids(&mut self, _stack: &mut Vec) {} +} + +/// `HeapRead`-based dispatch for `Decimal`, letting `HeapReadOutput` delegate +/// `PyTrait` calls to heap-resident decimals. Operations clone the value out +/// of the heap read (one small `BigInt` allocation — post-`fix` coefficients +/// are ≤ 28 digits) so the heap borrow ends before the VM is re-borrowed. +impl<'h> PyTrait<'h> for HeapRead<'h, Decimal> { + fn py_type(&self, _vm: &VM<'h, impl ResourceTracker>) -> Type { + Type::Decimal + } + + fn py_len(&self, _vm: &VM<'h, impl ResourceTracker>) -> Option { + None + } + + fn py_eq_impl(&self, other: &Value, vm: &mut VM<'h, impl ResourceTracker>) -> RunResult> { + // Exact CPython equality against the numeric tower (`int` / `bool` / + // `float` / `LongInt` / `Decimal`). `Ok(None)` (`NotImplemented`) for + // any non-number so `Value::py_eq` can try the reflected comparison. A + // quiet NaN compares unequal to everything; an sNaN raises. + cmp::eq_value(self.get(vm.heap), other, vm.heap) + } + + fn py_hash(&self, _self_id: HeapId, vm: &mut VM<'h, impl ResourceTracker>) -> RunResult> { + cmp::hash_decimal(self.get(vm.heap), vm.heap.tracker()).map(Some) + } + + fn py_bool(&self, vm: &mut VM<'h, impl ResourceTracker>) -> bool { + // Only zero is falsy — `bool(Decimal('NaN'))` and `bool(Decimal('Inf'))` + // are both `True`. + !self.get(vm.heap).is_zero() + } + + fn py_repr_fmt( + &self, + f: &mut impl fmt::Write, + vm: &mut VM<'h, impl ResourceTracker>, + _heap_ids: &mut LazyHeapSet, + ) -> RunResult<()> { + f.write_str("Decimal('")?; + write_decimal(self.get(vm.heap), true, f)?; + f.write_str("')")?; + Ok(()) + } + + fn py_str(&self, vm: &mut VM<'h, impl ResourceTracker>) -> RunResult { + Ok(allocate_string(canonical_string(self.get(vm.heap)), vm.heap)?) + } + + fn py_call_attr( + &mut self, + _self_id: HeapId, + vm: &mut VM<'h, impl ResourceTracker>, + attr: &EitherStr, + args: ArgValues, + ) -> RunResult { + let Some(method) = attr.static_string() else { + args.drop_with_heap(vm); + return Err(ExcType::attribute_error(Type::Decimal, attr.as_str(vm.interns))); + }; + let d = self.get(vm.heap).clone(); + // Every zero-argument method (predicate or value-producing) MUST + // validate its argument count *before* computing: the value-producing + // ones allocate a heap result that would leak — and panic under + // `memory-model-checks` — if a spurious argument were only rejected + // afterwards. `zero_arg!` enforces that order. + macro_rules! zero_arg { + ($value:expr) => {{ + methods::check_no_args(args, attr, vm)?; + Ok(CallResult::Value($value)) + }}; + } + match method { + // Zero-argument predicates (return bool). + StaticStrings::IsNan => zero_arg!(Value::Bool(d.is_nan())), + StaticStrings::IsQnan => zero_arg!(Value::Bool(d.is_qnan())), + StaticStrings::IsSnan => zero_arg!(Value::Bool(d.is_snan())), + StaticStrings::IsInfinite => zero_arg!(Value::Bool(d.is_infinite())), + StaticStrings::IsFinite => zero_arg!(Value::Bool(d.is_finite())), + StaticStrings::IsZero => zero_arg!(Value::Bool(d.is_zero())), + StaticStrings::IsSigned => zero_arg!(Value::Bool(d.is_signed())), + // Zero-argument methods returning a Decimal (or int, for `adjusted`). + StaticStrings::Sqrt => zero_arg!(trans::sqrt(d, vm)?), + StaticStrings::Ln => zero_arg!(trans::ln(&d, vm)?), + StaticStrings::Log10 => zero_arg!(trans::log10(&d, vm)?), + StaticStrings::Exp => zero_arg!(trans::exp(d, vm)?), + StaticStrings::Normalize => zero_arg!(methods::normalize(d, vm)?), + StaticStrings::CopyAbs => zero_arg!(allocate(d.copy_abs(), vm)?), + StaticStrings::CopyNegate => zero_arg!(allocate(d.copy_negate(), vm)?), + StaticStrings::AsTuple => zero_arg!(methods::as_tuple(&d, vm)?), + StaticStrings::Adjusted => zero_arg!(Value::Int(d.adjusted())), + // Methods with arguments (each validates its own arity/kwargs). + StaticStrings::Quantize => methods::quantize_method(d, args, vm), + StaticStrings::ToIntegralValue => methods::to_integral_value_method(d, args, vm), + StaticStrings::CopySign => methods::copy_sign_method(d, args, vm), + _ => { + args.drop_with_heap(vm); + Err(ExcType::attribute_error(Type::Decimal, attr.as_str(vm.interns))) + } + } + } +} diff --git a/crates/monty/src/types/decimal/parse.rs b/crates/monty/src/types/decimal/parse.rs new file mode 100644 index 000000000..3a47ac9e7 --- /dev/null +++ b/crates/monty/src/types/decimal/parse.rs @@ -0,0 +1,393 @@ +//! `Decimal(...)` construction: the string parser (a hand-rolled port of +//! `_pydecimal._parser`'s regex), exact `float` conversion, `int`/`LongInt` +//! conversion, the `(sign, digits, exponent)` sequence form, and the +//! constructor entry point. +//! +//! This file owns **guard 1**: every way a coefficient (or NaN payload) can +//! enter the system caps its digit count at [`DECIMAL_MAX_DIGITS`] and its +//! exponent at the C module's literal bounds, so no other module ever sees an +//! unbounded operand. + +use std::{borrow::Cow, str::from_utf8}; + +use num_bigint::BigInt; +use num_traits::Pow; + +use super::{DECIMAL_MAX_DIGITS, Decimal, MAX_LITERAL_EXP, MIN_LITERAL_EXP, allocate}; +use crate::{ + args::{ArgValues, FromArgs}, + bytecode::VM, + defer_drop, + exception_private::{ExcType, RunError, RunResult, SimpleException}, + heap::HeapData, + resource::ResourceTracker, + types::long_int::estimate_decimal_digits, + value::Value, +}; + +/// Arguments of `Decimal(value='0')`. CPython also accepts a `context` +/// argument; Monty has no `Context` objects, so passing one (positionally or +/// by name) is a `TypeError` (see `limitations/decimal.md`). +#[derive(FromArgs)] +#[from_args(name = "Decimal", style = c)] +struct DecimalInitArgs { + #[from_args(default)] + value: Option, +} + +/// Constructor for `decimal.Decimal(value='0')`. +/// +/// Accepts the input types CPython's `Decimal(...)` does: `str`, `int` (incl. +/// `bool` and heap `LongInt`), `float` (exact binary expansion), another +/// `Decimal` (copied), and the `(sign, digits, exponent)` sequence form. Zero +/// args yields `Decimal('0')`. A type Monty cannot convert (`None`, +/// containers, …) raises CPython's `TypeError: conversion from {type} to +/// Decimal is not supported`; an unparsable string raises `InvalidOperation` +/// with the `[]` message. +pub(crate) fn init(vm: &mut VM<'_, impl ResourceTracker>, args: ArgValues) -> RunResult { + let DecimalInitArgs { value } = DecimalInitArgs::from_args(args, vm)?; + let Some(value) = value else { + return allocate(Decimal::zero(), vm); + }; + defer_drop!(value, vm); + let d = decimal_from_value(value, vm)?; + allocate(d, vm) +} + +/// Converts a Python value into a [`Decimal`] for the constructor. +pub(super) fn decimal_from_value(value: &Value, vm: &VM<'_, impl ResourceTracker>) -> RunResult { + match value { + // `bool` is an `int` subtype: `Decimal(True) == Decimal('1')`. + Value::Bool(b) => Ok(Decimal::from_i64(i64::from(*b))), + Value::Int(i) => Ok(Decimal::from_i64(*i)), + Value::Float(f) => Ok(from_float(*f)), + Value::InternString(id) => parse_str(vm.interns.get_str(*id)), + Value::Ref(heap_id) => match vm.heap.get(*heap_id) { + HeapData::Str(s) => parse_str(s.as_str()), + HeapData::LongInt(li) => decimal_from_bigint(li.inner()), + HeapData::Decimal(d) => Ok(d.clone()), + // The `(sign, digits, exponent)` tuple/list form (a `DecimalTuple` + // from `as_tuple` is a NamedTuple, so it round-trips too). + HeapData::Tuple(t) => decimal_from_sequence(t.as_slice(), vm), + HeapData::List(l) => decimal_from_sequence(l.as_slice(), vm), + HeapData::NamedTuple(nt) => decimal_from_sequence(nt.as_vec(), vm), + _ => Err(ExcType::decimal_unsupported_conversion(value.py_type_name(vm))), + }, + other => Err(ExcType::decimal_unsupported_conversion(other.py_type_name(vm))), + } +} + +/// Exact conversion of an `int` (as `BigInt`) — guarded by the digit cap: +/// CPython accepts `Decimal(10**100000)`, Monty rejects past +/// [`DECIMAL_MAX_DIGITS`] (documented divergence). +pub(super) fn decimal_from_bigint(n: &BigInt) -> RunResult { + check_digit_cap_bits(n)?; + let (sign, magnitude) = split_sign(n); + Ok(Decimal::from_triple(sign, magnitude, 0)) +} + +/// Exact conversion of a `float`, matching `Decimal.from_float`: NaN/∞ map to +/// the decimal specials, a finite value to its *exact* binary expansion +/// (`Decimal(0.1)` is the 55-digit value). Bounded by construction: the +/// coefficient of any finite `f64` has ≤ 767 digits. +pub(super) fn from_float(f: f64) -> Decimal { + if f.is_nan() { + return Decimal::qnan(0, BigInt::ZERO); + } + let sign = u8::from(f.is_sign_negative()); + if f.is_infinite() { + return Decimal::infinity(sign); + } + if f == 0.0 { + return Decimal::from_triple(sign, BigInt::ZERO, 0); + } + // Decompose |f| = m · 2^e with m odd, then m · 2^e = (m · 5^-e) · 10^e for + // negative e — CPython's `n * 5**k` with `10**-k` exponent. + let bits = f.abs().to_bits(); + let raw_exp = i64::try_from((bits >> 52) & 0x7ff).expect("11-bit value fits i64"); + let mantissa = bits & ((1u64 << 52) - 1); + let (mut m, mut e): (u64, i64) = if raw_exp == 0 { + (mantissa, -1074) // subnormal: no implicit leading bit + } else { + (mantissa | (1 << 52), raw_exp - 1075) + }; + let trailing = m.trailing_zeros(); + m >>= trailing; + e += i64::from(trailing); + if e >= 0 { + Decimal::from_triple(sign, BigInt::from(m) << u64::try_from(e).expect("non-negative"), 0) + } else { + let k = u32::try_from(-e).expect("|e| <= 1074"); + Decimal::from_triple(sign, BigInt::from(m) * BigInt::from(5u8).pow(k), e) + } +} + +/// Parses a decimal string — the port of `_pydecimal._parser` plus its +/// pre-processing (`value.strip().replace('_', '')`): optional sign, then a +/// finite number (`digits[.digits][E±digits]`, needing at least one digit), +/// `Inf`/`Infinity`, or `[s]NaN[payload]`, all case-insensitive and +/// ASCII-only. An unparsable string raises CPython's +/// `InvalidOperation([ConversionSyntax])`; a coefficient or payload past the +/// digit cap raises the Monty-specific `ValueError`; an exponent outside the +/// C module's literal bounds raises `InvalidOperation`, as CPython does. +pub(super) fn parse_str(s: &str) -> RunResult { + let trimmed = s.trim(); + // CPython removes every underscore before parsing, so `_1_2.3_` is 12.3. + let cleaned: Cow<'_, str> = if trimmed.contains('_') { + Cow::Owned(trimmed.replace('_', "")) + } else { + Cow::Borrowed(trimmed) + }; + let bytes = cleaned.as_bytes(); + let (sign, rest) = match bytes.first() { + Some(b'+') => (0u8, &bytes[1..]), + Some(b'-') => (1u8, &bytes[1..]), + _ => (0u8, bytes), + }; + + if rest.eq_ignore_ascii_case(b"inf") || rest.eq_ignore_ascii_case(b"infinity") { + return Ok(Decimal::infinity(sign)); + } + // `[s]NaN` with an optional all-digit payload. + let nan_payload = if rest.len() >= 4 && rest[..4].eq_ignore_ascii_case(b"snan") { + Some((true, &rest[4..])) + } else if rest.len() >= 3 && rest[..3].eq_ignore_ascii_case(b"nan") { + Some((false, &rest[3..])) + } else { + None + }; + if let Some((signaling, payload)) = nan_payload { + if !payload.iter().all(u8::is_ascii_digit) { + return Err(ExcType::decimal_conversion_syntax()); + } + let digits = strip_leading_zeros(payload); + check_digit_cap(digits.len())?; + let payload = parse_coefficient(digits); + return Ok(if signaling { + Decimal::snan(sign, payload) + } else { + Decimal::qnan(sign, payload) + }); + } + + parse_finite(sign, rest) +} + +/// Parses the finite-number production `digits[.digits][E±digits]` (at least +/// one coefficient digit required, matching the regex's `(?=\d|\.\d)` +/// lookahead). +fn parse_finite(sign: u8, rest: &[u8]) -> RunResult { + let syntax_error = ExcType::decimal_conversion_syntax; + // Split off the exponent part at the first `e`/`E`. + let (mantissa, exp_lit) = match rest.iter().position(|&b| b == b'e' || b == b'E') { + Some(pos) => { + let exp_part = &rest[pos + 1..]; + let (exp_sign, exp_digits) = match exp_part.first() { + Some(b'+') => (1i64, &exp_part[1..]), + Some(b'-') => (-1i64, &exp_part[1..]), + _ => (1i64, exp_part), + }; + if exp_digits.is_empty() || !exp_digits.iter().all(u8::is_ascii_digit) { + return Err(syntax_error()); + } + // An exponent literal too large for i64 is far outside the C + // module's bounds: same `InvalidOperation` as any out-of-bounds + // exponent (the digits are already validated, so this is not a + // syntax error). + let magnitude = from_utf8(exp_digits) + .expect("ASCII digits") + .parse::() + .map_err(|_| ExcType::decimal_invalid_operation())?; + (&rest[..pos], exp_sign * magnitude) + } + None => (rest, 0i64), + }; + + // Split the coefficient at the decimal point; both sides all-digits, at + // least one digit overall. + let (int_part, frac_part) = match mantissa.iter().position(|&b| b == b'.') { + Some(pos) => (&mantissa[..pos], &mantissa[pos + 1..]), + None => (mantissa, [].as_slice()), + }; + if int_part.is_empty() && frac_part.is_empty() { + return Err(syntax_error()); + } + if !int_part.iter().all(u8::is_ascii_digit) || !frac_part.iter().all(u8::is_ascii_digit) { + return Err(syntax_error()); + } + + // Coefficient = int digits ++ frac digits, leading zeros stripped (cheaply, + // before the BigInt parse, so a megabyte of zeros costs a scan, not a + // quadratic parse). The exponent shifts down by the fraction length. + let mut digits = Vec::with_capacity(int_part.len() + frac_part.len()); + digits.extend_from_slice(int_part); + digits.extend_from_slice(frac_part); + let digits = strip_leading_zeros(&digits); + check_digit_cap(digits.len())?; + let frac_len = i64::try_from(frac_part.len()).map_err(|_| ExcType::decimal_invalid_operation())?; + let exp = exp_lit + .checked_sub(frac_len) + .ok_or_else(ExcType::decimal_invalid_operation)?; + + check_literal_exponent_bounds(exp, digits.len())?; + + Ok(Decimal::from_triple(sign, parse_coefficient(digits), exp)) +} + +/// The C module's literal bounds, shared by the string parser and the +/// `(sign, digits, exponent)` tuple form: the adjusted exponent may not exceed +/// `MAX_LITERAL_EXP` and the exponent may not undershoot `MIN_LITERAL_EXP` — +/// CPython raises `InvalidOperation` for either. Also the sandbox guard that +/// keeps every stored exponent safe for downstream i64 arithmetic. +fn check_literal_exponent_bounds(exp: i64, digits: usize) -> RunResult<()> { + let ndigits = i64::try_from(digits.max(1)).expect("digit count fits i64"); + if exp < MIN_LITERAL_EXP || exp.saturating_add(ndigits - 1) > MAX_LITERAL_EXP { + Err(ExcType::decimal_invalid_operation()) + } else { + Ok(()) + } +} + +/// Constructs a `Decimal` from the `(sign, digits, exponent)` sequence form, +/// e.g. `Decimal((0, (1, 2, 0), -2)) == Decimal('1.20')`. +/// +/// `sign` is `0`/`1` (a `bool` is accepted, being an `int` subtype); `digits` +/// is a tuple/list of single `0..=9` digits; `exponent` is an `int`, or +/// `'F'`/`'n'`/`'N'` for ∞ / NaN / sNaN (for `'F'` the digits are ignored; for +/// the NaN forms they become the payload). Each malformed element raises +/// CPython's exact `ValueError`; an exponent beyond i64 raises CPython's +/// `OverflowError`. +fn decimal_from_sequence(items: &[Value], vm: &VM<'_, impl ResourceTracker>) -> RunResult { + let heap = &vm.heap; + let [sign_value, digits_value, exponent_value] = items else { + return Err(decimal_sequence_error("argument must be a sequence of length 3")); + }; + let sign = match sign_value { + Value::Int(0) | Value::Bool(false) => 0u8, + Value::Int(1) | Value::Bool(true) => 1u8, + _ => return Err(decimal_sequence_error("sign must be an integer with the value 0 or 1")), + }; + + // Concatenate the coefficient digits (each a single `0..=9` int, `bool` + // accepted as `0`/`1`). A non-sequence `digits` or a non-digit element is + // a `ValueError`, matching CPython (not the parser's `ConversionSyntax`). + let digit_items = match digits_value { + Value::Ref(id) => match heap.get(*id) { + HeapData::Tuple(t) => t.as_slice(), + HeapData::List(l) => l.as_slice(), + _ => return Err(decimal_sequence_error(COEFFICIENT_NOT_DIGITS)), + }, + _ => return Err(decimal_sequence_error(COEFFICIENT_NOT_DIGITS)), + }; + let mut coefficient = Vec::with_capacity(digit_items.len()); + for digit in digit_items { + let value = match digit { + Value::Int(n @ 0..=9) => *n, + Value::Bool(b) => i64::from(*b), + _ => return Err(decimal_sequence_error(COEFFICIENT_NOT_DIGITS)), + }; + coefficient.push(b'0' + u8::try_from(value).expect("0..=9")); + } + let coefficient = strip_leading_zeros(&coefficient); + check_digit_cap(coefficient.len())?; + + // The exponent is an `int` (`bool` included), or a special-value marker + // string `'F'`/`'n'`/`'N'` (which may be interned or heap-allocated). An + // `int` exponent must satisfy the same C-module literal bounds as the + // string parser — an unbounded exponent would otherwise overflow the i64 + // exponent arithmetic downstream (CPython raises `InvalidOperation` too). + match exponent_value { + Value::Int(exp) => { + check_literal_exponent_bounds(*exp, coefficient.len())?; + Ok(Decimal::from_triple(sign, parse_coefficient(coefficient), *exp)) + } + Value::Bool(b) => Ok(Decimal::from_triple( + sign, + parse_coefficient(coefficient), + i64::from(*b), + )), + // A heap `LongInt` exponent is beyond i64: CPython's `OverflowError`. + Value::Ref(id) if let HeapData::LongInt(_) = heap.get(*id) => Err(ExcType::int_too_large_for_ssize_t()), + other => { + let marker = match other { + Value::InternString(id) => Some(vm.interns.get_str(*id)), + Value::Ref(id) if let HeapData::Str(s) = heap.get(*id) => Some(s.as_str()), + _ => None, + }; + match marker { + Some("F") => Ok(Decimal::infinity(sign)), + Some("n") => Ok(Decimal::qnan(sign, parse_coefficient(coefficient))), + Some("N") => Ok(Decimal::snan(sign, parse_coefficient(coefficient))), + // A string in the third position must be a valid marker; + // anything else (e.g. a `float` exponent) "must be an integer" + // — distinct CPython messages. + Some(_) => Err(decimal_sequence_error( + "string argument in the third position must be 'F', 'n' or 'N'", + )), + None => Err(decimal_sequence_error("exponent must be an integer")), + } + } + } +} + +/// CPython's `ValueError` message for a non-digit-tuple coefficient in the +/// sequence form (used for both a non-sequence `digits` and a non-digit +/// element). +const COEFFICIENT_NOT_DIGITS: &str = "coefficient must be a tuple of digits"; + +/// A `ValueError` for a malformed `Decimal((sign, digits, exponent))` +/// sequence, carrying CPython's exact wording for the offending element. +fn decimal_sequence_error(message: &str) -> RunError { + SimpleException::new_msg(ExcType::ValueError, message.to_owned()).into() +} + +/// The digit slice without leading `'0'`s (empty for an all-zero slice). +fn strip_leading_zeros(digits: &[u8]) -> &[u8] { + let start = digits.iter().position(|&b| b != b'0').unwrap_or(digits.len()); + &digits[start..] +} + +/// Parses a validated ASCII-digit slice into the coefficient `BigInt` +/// (`BigInt::ZERO` for an empty slice). +fn parse_coefficient(digits: &[u8]) -> BigInt { + if digits.is_empty() { + BigInt::ZERO + } else { + BigInt::parse_bytes(digits, 10).expect("validated ASCII digits") + } +} + +/// Guard 1 for digit counts already known exactly. +fn check_digit_cap(digits: usize) -> RunResult<()> { + if digits > DECIMAL_MAX_DIGITS { + Err(ExcType::decimal_digits_limit()) + } else { + Ok(()) + } +} + +/// Guard 1 for a `BigInt` operand, using the bit length so a huge `int` is +/// rejected without ever stringifying it (`digits ≈ bits · log10(2)`; the +/// bound errs high by < 1 digit, and the exact check settles the boundary). +fn check_digit_cap_bits(n: &BigInt) -> RunResult<()> { + // The estimate is a strict upper bound on the digit count, so an in-cap + // estimate proves the operand fits with no stringify at all; only the + // one-digit boundary window needs the exact count. + let approx_digits = estimate_decimal_digits(n.bits()); + if approx_digits <= DECIMAL_MAX_DIGITS as u64 { + Ok(()) + } else if approx_digits > DECIMAL_MAX_DIGITS as u64 + 1 { + Err(ExcType::decimal_digits_limit()) + } else { + check_digit_cap(n.magnitude().to_string().len()) + } +} + +/// Splits a `BigInt` into `(decimal sign, magnitude)`. +fn split_sign(n: &BigInt) -> (u8, BigInt) { + if n.sign() == num_bigint::Sign::Minus { + (1, -n) + } else { + (0, n.clone()) + } +} diff --git a/crates/monty/src/types/decimal/pow.rs b/crates/monty/src/types/decimal/pow.rs new file mode 100644 index 000000000..35eca9530 --- /dev/null +++ b/crates/monty/src/types/decimal/pow.rs @@ -0,0 +1,654 @@ +//! `Decimal ** Decimal` and `pow(Decimal, Decimal, Decimal)` — ports of +//! `_pydecimal.__pow__` (lines 2252–2466), `_power_exact` (2004–2250) and +//! `_power_modulo` (1919–2002), under the fixed context (`prec = 28`, +//! `ROUND_HALF_EVEN`, `Emax = 999999`, `clamp = 0`). +//! +//! The two-argument power first resolves the special cases (NaNs, `x ** 0`, +//! zeros, infinities, `1 ** y`, the crude overflow/underflow bound), then +//! attempts an *exact* result via [`power_exact`], and only falls back to the +//! correctly-rounded `exp(y·log(x))` kernel ([`trans::dpower`]) when +//! exactness is ruled out. Every `len(str(...))` early-exit and `emax` cap in +//! `_power_exact` is a load-bearing DoS guard bounding the `10**e` / `5**e` / +//! `2**e` / `xc**m` materialisations; [`pow10`] and +//! [`check_pow_size`] re-check as defence in depth (guard 2 in the module +//! docs), and the Newton nth-root / roundability-refinement / modular-power +//! loops poll `check_time` (guard 3). + +use num_bigint::BigInt; +use num_integer::Integer; +use num_traits::{One, Pow, ToPrimitive, Zero}; + +use super::{ + DEFAULT_PREC, Decimal, EMAX, ETINY, PREC, RoundMode, allocate, check_nans, fix, magnitude_digits, pow10, trans, +}; +use crate::{ + bytecode::VM, + exception_private::{ExcType, RunError, RunResult}, + resource::{ResourceTracker, check_pow_size}, + value::Value, +}; + +/// `Decimal ** Decimal` — the two-argument `__pow__` (`_pydecimal` +/// 2252–2466, `modulo=None` path), dispatched from `arith`'s `BinOp::Pow`. +/// +/// Special cases return directly (CPython does not `_fix` them); everything +/// past the "from here on" comment (2365) is finalised by [`fix::fix`]. Under +/// the fixed context the only trapped signals are `InvalidOperation` (sNaN +/// operands, `0 ** 0`, a negative base with a non-integral exponent) and +/// `Overflow`; the `Inexact`/`Rounded`/`Underflow`/`Subnormal` raises in the +/// original are untrapped no-ops and are omitted. +pub(super) fn power(a: Decimal, b: &Decimal, vm: &mut VM<'_, impl ResourceTracker>) -> RunResult { + // Either argument is a NaN => the result is a NaN (2286-2289). This comes + // BEFORE the `x ** 0` check: `Decimal('NaN') ** 0` is NaN, not 1. + if let Some(nan) = check_nans(&a, Some(b))? { + return allocate(nan, vm); + } + + // 0**0 raises InvalidOperation(!); x**0 = 1 for any other x, including + // ±Infinity (2291-2296). + if b.is_zero() { + return if a.is_zero() { + Err(ExcType::decimal_invalid_operation()) + } else { + allocate(Decimal::from_i64(1), vm) + }; + } + + // The result is negative iff the base is negative and the exponent an odd + // integer; a nonzero negative base with a non-integral exponent is + // invalid, and `(-0) ** noninteger` falls through as `0 ** noninteger` + // (2298-2311). The base is then made positive for the computation. + let mut result_sign = 0u8; + let a = if a.sign == 1 { + if is_integer(b) { + if !is_even(b) { + result_sign = 1; + } + } else if !a.is_zero() { + // -ve ** noninteger = NaN ('x ** y with x negative and y not an + // integer' in _pydecimal; the C module's fixed message here). + return Err(ExcType::decimal_invalid_operation()); + } + a.copy_negate() + } else { + a + }; + + // 0**(+ve or Inf) = 0; 0**(-ve or -Inf) = Infinity (2313-2318). + if a.is_zero() { + return allocate( + if b.sign == 0 { + Decimal::from_triple(result_sign, BigInt::ZERO, 0) + } else { + Decimal::infinity(result_sign) + }, + vm, + ); + } + + // Inf**(+ve or Inf) = Inf; Inf**(-ve or -Inf) = 0 (2320-2325). + if a.is_infinite() { + return allocate( + if b.sign == 0 { + Decimal::infinity(result_sign) + } else { + Decimal::from_triple(result_sign, BigInt::ZERO, 0) + }, + vm, + ); + } + + // 1**other = 1, with the exponent CPython prescribes (2327-2352). This + // deliberately precedes the b-infinite case: `Decimal(1) ** Decimal('Inf')` + // is `1.000...0` at full precision. The multiplier is int(b) clamped into + // [0, prec], evaluated without materialising a huge int(b) (b could be + // 1E999999999); the untrapped Inexact/Rounded raises are omitted. + if is_numerically_one(&a) { + let exp = if is_integer(b) { + let multiplier = if b.sign == 1 { + 0 + } else if b.adjusted() >= 2 { + // b >= 100 > prec, settled without computing int(b). + PREC + } else { + // b < 100: int(b) is tiny; clamp at prec. + integral_magnitude(b, vm.heap.tracker())? + .to_i64() + .expect("integer below 100 fits i64") + .min(PREC) + }; + // a == 1 has a.exp <= 0, so the product is <= 0. + a.exp.saturating_mul(multiplier).max(1 - PREC) + } else { + 1 - PREC + }; + // '1' + '0'*-exp with exp in [1-prec, 0] (2352). + let coeff = pow10(-exp, vm.heap.tracker())?; + return allocate(Decimal::from_triple(result_sign, coeff, exp), vm); + } + + // Adjusted exponent of the (now positive, non-1) base (2354-2355). + let self_adj = a.adjusted(); + + // a ** ±Infinity resolves by whether a is above or below 1 (2357-2363). + if b.is_infinite() { + return allocate( + if (b.sign == 0) == (self_adj < 0) { + Decimal::from_triple(result_sign, BigInt::ZERO, 0) + } else { + Decimal::infinity(result_sign) + }, + vm, + ); + } + + // From here on the result always goes through `fix` (2365-2368). + let mut exact = false; + + // Crude bound catching extreme overflow/underflow WITHOUT computing the + // power (2370-2386): if log10(a)·b is at least 10**len(str(Emax)) the + // result is past Emax (or below Etiny, in the mirrored case), so a + // sentinel value lets `fix` do the raising (Overflow) or the quiet + // rounding to a signed zero (underflow, untrapped). + let bound = trans::log10_exp_bound(&a, vm.heap.tracker())?.saturating_add(b.adjusted()); + let mut ans = if (self_adj >= 0) == (b.sign == 0) { + // a > 1 with b positive, or a < 1 with b negative: possible overflow. + (bound >= dec_len(EMAX)).then(|| Decimal::from_triple(result_sign, BigInt::from(1), EMAX + 1)) + } else { + // a > 1 with b negative, or a < 1 with b positive: possible underflow. + (bound >= dec_len(-ETINY)).then(|| Decimal::from_triple(result_sign, BigInt::from(1), ETINY - 1)) + }; + + // Try for an exact result with precision + 1 (2388-2394). + if ans.is_none() { + ans = power_exact(&a, b, PREC + 1, vm.heap.tracker())?; + if let Some(exact_ans) = ans.as_mut() { + exact_ans.sign = result_sign; + exact = true; + } + } + + // Usual case: inexact result, x**y computed as exp(y·log(x)) (2396-2415). + let ans = if let Some(ans) = ans { + ans + } else { + // _WorkRep values: unlike _power_exact these keep any trailing + // zeros (2399-2404). + let xc = a.coeff; + let xe = a.exp; + let mut yc = b.coeff.clone(); + let ye = b.exp; + if b.sign == 1 { + yc = -yc; + } + // Start at precision + 3, widening until the result is + // unambiguously roundable — i.e. the digits past the precision are + // not an exact `...5000`/`...0000` tail (2406-2414). The iteration + // cap and per-pass time poll are sandbox guards (guard 3); + // CPython's loop terminates on the same condition but unboundedly. + let mut extra: i64 = 3; + loop { + vm.heap.check_time()?; + let (coeff, exp) = trans::dpower(&xc, xe, &yc, ye, PREC + extra, vm.heap.tracker())?; + // coeff has p+extra or p+extra+1 digits (dpower's contract), + // so the cut is at least extra - 1 >= 2. + let cut = i64::try_from(coeff.to_string().len()).expect("digit count fits i64") - PREC - 1; + let modulus = BigInt::from(5) * pow10(cut, vm.heap.tracker())?; + if !(&coeff % &modulus).is_zero() { + break Decimal::from_triple(result_sign, coeff, exp); + } + extra += 3; + if extra > 301 { + return Err(RunError::internal("decimal pow did not converge")); + } + } + }; + + // The power function respects the context rounding mode (2417-2418) — + // already ROUND_HALF_EVEN under the fixed context, nothing to switch. + // + // Exact result with a non-integer exponent (2430-2461): CPython pads the + // coefficient to prec+1 digits, `_fix`es in a traps-cleared copy of the + // context, then re-raises the trapped signals from the original. Under + // the fixed context that dance collapses: Inexact/Underflow/Subnormal/ + // Rounded/Clamped are untrapped no-ops, so the only surviving effect is + // that a result past Emax still raises Overflow (2457-2458) — which the + // plain `fix` below already does. + let ans = if exact && !is_integer(b) && ans.digits() <= DEFAULT_PREC { + let expdiff = PREC + 1 - i64::try_from(ans.digits()).expect("digit count fits i64"); + Decimal::from_triple( + ans.sign, + ans.coeff * pow10(expdiff, vm.heap.tracker())?, + ans.exp - expdiff, + ) + } else { + ans + }; + allocate(fix::fix(ans, RoundMode::HalfEven)?, vm) +} + +/// Three-argument `pow(Decimal, Decimal, Decimal)` — `_pydecimal._power_modulo` +/// (1919–2002), exported for the `pow()` builtin. +/// +/// The restrictions of Python's integer `pow` apply, each raising +/// `InvalidOperation` — with the C module's fixed +/// `[]` message rather than `_pydecimal`'s +/// prose ("pow() 3rd argument not allowed unless all arguments are integers", +/// etc.): all three operands integral (which also rejects infinities), the +/// exponent non-negative, the modulus nonzero with at most `prec` digits, and +/// not `0 ** 0`. The result is exact modular exponentiation and — like +/// CPython — is returned without `_fix`. +pub(crate) fn power_modulo( + a: Decimal, + b: Decimal, + m: Decimal, + vm: &mut VM<'_, impl ResourceTracker>, +) -> RunResult { + // NaN handling matches fma: any sNaN raises, otherwise the first quiet + // NaN in argument order propagates (1932-1951). + if a.is_snan() || b.is_snan() || m.is_snan() { + return Err(ExcType::decimal_invalid_operation()); + } + if a.is_qnan() { + return allocate(fix::fix_nan(a), vm); + } + if b.is_qnan() { + return allocate(fix::fix_nan(b), vm); + } + if m.is_qnan() { + return allocate(fix::fix_nan(m), vm); + } + + // Same restrictions as Python's pow() (1953-1959). + if !(is_integer(&a) && is_integer(&b) && is_integer(&m)) { + return Err(ExcType::decimal_invalid_operation()); + } + // The exponent cannot be negative (1960-1963); `-0` is not negative. + if b.sign == 1 && !b.is_zero() { + return Err(ExcType::decimal_invalid_operation()); + } + // The modulus cannot be zero (1964-1966). + if m.is_zero() { + return Err(ExcType::decimal_invalid_operation()); + } + // The modulus must be less than 10**prec in absolute value (1968-1974). + if m.adjusted() >= PREC { + return Err(ExcType::decimal_invalid_operation()); + } + // 0**0 is undefined, for consistency with two-argument pow (1976-1982). + if b.is_zero() && a.is_zero() { + return Err(ExcType::decimal_invalid_operation()); + } + + // The result is negative only for a negative base with an odd exponent + // (1984-1988); the modulus sign is ignored. + let sign = if is_even(&b) { 0 } else { a.sign }; + + // modulo = abs(int(modulo)) — at most prec digits by the check above + // (1990-1992) — and base/exponent as integer WorkReps with exp >= 0. + let modulo = integral_magnitude(&m, vm.heap.tracker())?; + let (base_coeff, base_exp) = integral_workrep(&a)?; + let (exp_coeff, exp_exp) = integral_workrep(&b)?; + + // base = (base.int % modulo * pow(10, base.exp, modulo)) % modulo (1997): + // the power-of-ten factor is folded in modularly, so a huge base exponent + // never materialises. + let ten = BigInt::from(10); + let mut base = (&base_coeff % &modulo) * ten.modpow(&BigInt::from(base_exp), &modulo) % &modulo; + + // for i in range(exponent.exp): base = pow(base, 10, modulo) (1998-1999). + // `exponent.exp` is attacker-sized (`pow(x, Decimal('1E18'), m)` loops + // that many times — in CPython too), so each cheap modular pass polls the + // time limit (guard 3). + for _ in 0..exp_exp { + vm.heap.check_time()?; + base = base.modpow(&ten, &modulo); + } + // base = pow(base, exponent.int, modulo) (2000). + base = base.modpow(&exp_coeff, &modulo); + + allocate(Decimal::from_triple(sign, base, 0), vm) +} + +/// `_pydecimal._power_exact` (2004–2250): attempts to compute `a ** b` +/// exactly with `p` digits of precision, returning `None` when the result is +/// not exactly representable (the caller then computes it inexactly). +/// +/// Preconditions, established by [`power`]: both operands finite, `a` +/// positive and not numerically 1, `b` nonzero. The method is engineered to +/// detect *failure* cheaply: every `len(str(...))` early-exit and `emax` cap +/// is kept in CPython's order because together they bound all the big-integer +/// materialisations; `Ok(None)` on the (unreachable) out-of-`i64` exponent +/// paths falls back to the inexact kernel, whose `fix` overflows exactly +/// where CPython's would. +#[expect(clippy::many_single_char_names, reason = "ported line-for-line from _pydecimal")] +fn power_exact(a: &Decimal, b: &Decimal, p: i64, tracker: &impl ResourceTracker) -> RunResult> { + // WorkReps with powers of 10 shifted out of the coefficients (2062-2072): + // x = xc·10^xe and |y| = yc·10^ye with xc, yc not divisible by 10. + let mut xc = a.coeff.clone(); + let mut xe = a.exp; + shift_out_tens(&mut xc, &mut xe); + let mut yc = b.coeff.clone(); + let mut ye = b.exp; + shift_out_tens(&mut yc, &mut ye); + + // Case xc == 1: the result is 10**(xe·y), with xe·y required to be an + // integer (2074-2093). + if xc.is_one() { + // xe *= yc, then shift the 10s into ye; nonzero because a != 1 forces + // xe != 0 here (2077-2081). BigInt: xe·yc can exceed i64. + let mut xe = BigInt::from(xe) * &yc; + shift_out_tens(&mut xe, &mut ye); + if ye < 0 { + return Ok(None); + } + // exponent = xe·10^ye (2084-2086), bounded to ~10**len(str(Emax)) by + // __pow__'s overflow/underflow shortcut; `pow10` re-checks (guard 2). + let mut exponent = xe * pow10(ye, tracker)?; + if b.sign == 1 { + exponent = -exponent; + } + // For a non-negative integer b, shed zeros toward the ideal exponent + // self._exp·int(b) (2087-2092); `exponent - ideal` is non-negative + // because the shifted-out 10s only ever raised xe above a.exp. + let zeros = if is_integer(b) && b.sign == 0 { + let ideal = BigInt::from(a.exp) * integral_magnitude(b, tracker)?; + (&exponent - ideal) + .min(BigInt::from(p - 1)) + .to_i64() + .ok_or_else(|| RunError::internal("decimal pow: ideal-exponent shift out of bounds"))? + } else { + 0 + }; + // '1' + '0'*zeros at exponent - zeros (2093). + return Ok(match (exponent - BigInt::from(zeros)).to_i64() { + Some(exp) => Some(Decimal::from_triple(0, pow10(zeros, tracker)?, exp)), + // Unreachable (the shortcut bounds |log10(x)·y|): bail to inexact. + None => None, + }); + } + + let ten = BigInt::from(10); + + // Case y negative: xc must be a power of 2 or a power of 5 (2095-2183). + if b.sign == 1 { + let last_digit = (&xc % &ten).to_u8().expect("remainder below 10 fits u8"); + // `e` with xc = 2**e (resp. 5**e); the result coefficient will be + // `coeff_base**e` = 5**e (resp. 2**e). + let (e, emax, coeff_base): (i64, i64, u8) = match last_digit { + 2 | 4 | 6 | 8 => { + // Quick power-of-2 test — CPython's `xc & -xc == xc` (2100-2104). + if xc.magnitude().count_ones() != 1 { + return Ok(None); + } + // The exact result is 5**(-e·y) · 10**(e·y + xe·y). `emax` is + // the largest e with 5**e < 10**p (93/65 bounds + // log(10)/log(5)); a ye at which -e·y necessarily exceeds it + // exits before anything is materialised (2106-2133). + let emax = p * 93 / 65; + if ye >= dec_len(emax) { + return Ok(None); + } + (i64::try_from(xc.bits()).expect("bit count fits i64") - 1, emax, 5) + } + 5 => { + // e >= log5(xc) whenever xc is a power of 5 (2145-2154); e is + // bounded by the digit cap on xc, `check_pow_size` as defence. + let mut e = i64::try_from(xc.bits()).expect("bit count fits i64") * 28 / 65; + check_pow_size(3, u64::try_from(e).expect("non-negative"), tracker)?; + let (q, r) = BigInt::from(5) + .pow(u32::try_from(e).expect("bounded by coefficient bits")) + .div_rem(&xc); + if !r.is_zero() { + return Ok(None); + } + xc = q; + let five = BigInt::from(5); + loop { + let (q, r) = xc.div_rem(&five); + if r.is_zero() { + xc = q; + e -= 1; + } else { + break; + } + } + // Same ye guard, with 10/3 bounding log(10)/log(2) (2156-2161). + let emax = p * 10 / 3; + if ye >= dec_len(emax) { + return Ok(None); + } + (e, emax, 2) + } + // Odd last digit other than 5: not a power of 2 or 5 (2171-2172). + _ => return Ok(None), + }; + + // -e·y and -xe·y must both be integers (2135-2139 / 2163-2166), and + // the coefficient exponent may not exceed emax (2141 / 2168). + let Some(e_big) = trans::decimal_lshift_exact(&(BigInt::from(e) * &yc), ye) else { + return Ok(None); + }; + let Some(xe_big) = trans::decimal_lshift_exact(&(BigInt::from(xe) * &yc), ye) else { + return Ok(None); + }; + if e_big > BigInt::from(emax) { + return Ok(None); + } + let exponent = e_big.to_u32().expect("0 < e <= emax"); + let xc = BigInt::from(coeff_base).pow(exponent); + // An exact power of 10 is impossible here (asserts at 2177-2178); a + // coefficient wider than p digits is not representable (2179-2181). + if i64::try_from(xc.to_string().len()).expect("digit count fits i64") > p { + return Ok(None); + } + // xe = -e - xe (2182-2183); an out-of-i64 exponent is unreachable + // (the __pow__ shortcut bounds |log10(x)·y|) — bail to inexact. + return Ok((-e_big - xe_big).to_i64().map(|exp| Decimal::from_triple(0, xc, exp))); + } + + // Now y is positive: write y = m/n in lowest terms (2185-2200). + let (m, n) = if ye >= 0 { + (&yc * pow10(ye, tracker)?, BigInt::from(1)) + } else { + // |y| < 1/|xe| or |y| <= 1/nbits(xc) means x**y cannot be exactly + // representable (2187-2193); these two exits also bound the + // 10**(-ye) denominator materialised just below. + if xe != 0 && magnitude_digits(&(&yc * BigInt::from(xe))) <= -ye { + return Ok(None); + } + let xc_bits = i64::try_from(xc.bits()).expect("bit count fits i64"); + if magnitude_digits(&(&yc * BigInt::from(xc_bits))) <= -ye { + return Ok(None); + } + let mut m = yc.clone(); + let mut n = pow10(-ye, tracker)?; + // Cancel common factors of 2 and 5 (2195-2200). + for factor in [2u8, 5] { + let factor = BigInt::from(factor); + loop { + let (mq, mr) = m.div_rem(&factor); + let (nq, nr) = n.div_rem(&factor); + if mr.is_zero() && nr.is_zero() { + m = mq; + n = nq; + } else { + break; + } + } + } + (m, n) + }; + + // Compute the nth root of xc·10^xe when n > 1 (2202-2222). + if n > BigInt::from(1) { + // 1 < xc < 2**n cannot be an nth power (2204-2206) — this comparison + // is also what shrinks n from a potentially huge 10**(-ye) down to + // less than the coefficient's bit count. + let xc_bits = i64::try_from(xc.bits()).expect("bit count fits i64"); + if BigInt::from(xc_bits) <= n { + return Ok(None); + } + let n = n.to_i64().expect("n below xc bit count fits i64"); + // xe must be divisible by n (2208-2210); Python divmod is floored. + let (quot, rem) = xe.div_mod_floor(&n); + if rem != 0 { + return Ok(None); + } + xe = quot; + // Newton's method from above (2212-2222): the initial estimate + // 2**ceil(bits/n) is >= the true root, so the iterate decreases + // monotonically; the per-pass time poll is guard 3. + let mut root = + BigInt::from(1) << u64::try_from(num_integer::Integer::div_ceil(&xc_bits, &n)).expect("positive shift"); + let root_exp = u32::try_from(n - 1).expect("n bounded by coefficient bit count"); + let (root, quot, rem) = loop { + tracker.check_time()?; + let (q, r) = xc.div_rem(&Pow::pow(&root, root_exp)); + if root <= q { + break (root, q, r); + } + root = (&root * BigInt::from(n - 1) + q) / BigInt::from(n); + }; + if !(root == quot && rem.is_zero()) { + return Ok(None); + } + xc = root; + } + + // Compute the mth power of the root (2224-2240). The `_log10_lb` bound is + // the load-bearing guard: past it xc**m > 10**p, so an unrepresentable + // power is never materialised (xc > 1 always holds here — kept as CPython + // wrote it, and it also shields `log10_lb` from a zero denominator). + if xc > BigInt::from(1) && m > BigInt::from(p * 100 / trans::log10_lb(&xc)) { + return Ok(None); + } + let m_small = m + .to_u32() + .ok_or_else(|| RunError::internal("decimal pow: unbounded exact exponent"))?; + check_pow_size(xc.bits(), u64::from(m_small), tracker)?; + xc = xc.pow(m_small); + // Unreachable overflow (the __pow__ shortcut bounds |xe·m|): bail to inexact. + let Ok(xe) = i64::try_from(i128::from(xe) * i128::from(m_small)) else { + return Ok(None); + }; + let str_xc = xc.to_string(); + if i64::try_from(str_xc.len()).expect("digit count fits i64") > p { + return Ok(None); + } + + // The result is exactly representable: shed zeros toward the ideal + // exponent self._exp·int(b) (2242-2250). For a (positive) integer b the + // reduction left n == 1 and m == int(b) exactly, so m is reused here. + let zeros = if is_integer(b) && b.sign == 0 { + let ideal = BigInt::from(a.exp) * &m; + let cap = p - i64::try_from(str_xc.len()).expect("digit count fits i64"); + (BigInt::from(xe) - ideal) + .min(BigInt::from(cap)) + .to_i64() + .ok_or_else(|| RunError::internal("decimal pow: ideal-exponent shift out of bounds"))? + } else { + 0 + }; + Ok(Some(Decimal::from_triple(0, xc * pow10(zeros, tracker)?, xe - zeros))) +} + +/// `abs(int(d))` for a finite integral `d` — exact by construction (a +/// negative `d.exp` on an integral value only strips trailing zeros). Serves +/// `_power_modulo`'s `abs(int(modulo))` and the ideal-exponent `int(other)` +/// computations, whose call sites all bound `d` first; [`pow10`] re-checks. +fn integral_magnitude(d: &Decimal, tracker: &impl ResourceTracker) -> RunResult { + debug_assert!(is_integer(d), "integral_magnitude requires an integral operand"); + Ok(if d.exp >= 0 { + &d.coeff * pow10(d.exp, tracker)? + } else { + &d.coeff / pow10(-d.exp, tracker)? + }) +} + +/// `_WorkRep(d.to_integral_value())` (1993–1994): the `(coeff, exp)` pair of +/// an integral `d` with the exponent forced non-negative. The rescale is +/// exact — `_power_modulo` verified integrality — so the default rounding +/// mode never actually rounds. +fn integral_workrep(d: &Decimal) -> RunResult<(BigInt, i64)> { + if d.exp >= 0 { + Ok((d.coeff.clone(), d.exp)) + } else { + let rescaled = fix::rescale(d, 0, RoundMode::HalfEven)?; + Ok((rescaled.coeff, rescaled.exp)) + } +} + +/// `Decimal._isinteger` (2856–2863): finite with no nonzero fractional digit +/// (so `Decimal('1.00')` and `Decimal('1E5')` are integers, specials never). +fn is_integer(d: &Decimal) -> bool { + if d.is_special() { + false + } else if d.exp >= 0 { + true + } else { + // CPython's `self._int[self._exp:]`: the last -exp coefficient digits + // (all of them when -exp exceeds the digit count) must be zeros. + let s = d.coeff_str(); + let frac = usize::try_from(-d.exp).unwrap_or(usize::MAX).min(s.len()); + s.as_bytes()[s.len() - frac..].iter().all(|&b| b == b'0') + } +} + +/// `Decimal._iseven` (2865–2869) — assumes `d` is a finite integer: zero and +/// positive-exponent values (trailing base-10 zeros) are even; otherwise the +/// last integral-position digit decides. +fn is_even(d: &Decimal) -> bool { + if d.is_zero() || d.exp > 0 { + true + } else { + // CPython's `self._int[-1 + self._exp]`. A nonzero integer always has + // more digits than trailing fractional zeros, so the index is valid; + // the defensive `is_none_or` avoids a panic on a (contract-violating) + // non-integer. + let s = d.coeff_str(); + usize::try_from(-d.exp) + .ok() + .and_then(|shift| s.len().checked_sub(shift + 1)) + .is_none_or(|idx| matches!(s.as_bytes()[idx], b'0' | b'2' | b'4' | b'6' | b'8')) + } +} + +/// Whether `d` is numerically equal to 1 in any representation — CPython's +/// `self == _One` (2330): positive, coefficient `10**k`, exponent `-k` +/// (`1`, `1.0`, `1.00`, … all match; `10` does not). +fn is_numerically_one(d: &Decimal) -> bool { + if !d.is_finite() || d.sign != 0 { + return false; + } + let s = d.coeff_str(); + let bytes = s.as_bytes(); + bytes[0] == b'1' + && bytes[1..].iter().all(|&b| b == b'0') + && i64::try_from(s.len()).expect("digit count fits i64") == 1 - d.exp +} + +/// Shifts factors of 10 out of `c` into `e` — the `while c % 10 == 0` +/// `_WorkRep` normalisation in `_power_exact` (2064–2072, 2079–2081). `c` +/// must be nonzero (guaranteed: the operands are nonzero) or the loop would +/// never terminate; iterations are bounded by `c`'s digit count. +fn shift_out_tens(c: &mut BigInt, e: &mut i64) { + debug_assert!(!c.is_zero(), "shift_out_tens requires a nonzero value"); + let ten = BigInt::from(10); + loop { + let (q, r) = c.div_rem(&ten); + if r.is_zero() { + *c = q; + *e += 1; + } else { + break; + } + } +} + +/// `len(str(n))` for a non-negative `n` — CPython's digit-count idiom used by +/// the `_power_exact` guards and the `__pow__` overflow bound. +fn dec_len(n: i64) -> i64 { + debug_assert!(n >= 0, "dec_len takes a non-negative value"); + i64::try_from(n.to_string().len()).expect("length fits i64") +} diff --git a/crates/monty/src/types/decimal/trans.rs b/crates/monty/src/types/decimal/trans.rs new file mode 100644 index 000000000..df81f4ef6 --- /dev/null +++ b/crates/monty/src/types/decimal/trans.rs @@ -0,0 +1,834 @@ +//! Transcendental `Decimal` operations — `sqrt`, `exp`, `ln`, `log10` — and +//! the fixed-point integer kernels behind them (and behind `__pow__`), ported +//! line-for-line from `_pydecimal.py` (CPython 3.14: `sqrt` 2681-2778, `exp` +//! 3001-3074, `ln` 3132-3205, `log10` 3207-3286, kernels 5651-5992). +//! +//! The kernels represent a real number `z` as an integer approximation to +//! `z * M` for a power-of-ten scale `M` and carry carefully analysed error +//! bounds (documented in the originals); they are deliberately NOT +//! "improved" — every `//`, `%`, and `>>` is Python floor semantics, mapped +//! to `num_integer`'s `div_floor`/`mod_floor` (the only negative-operand +//! shifts are re-expressed through them too, so no reliance on `BigInt` +//! bit-op sign semantics). +//! +//! Sandbox guards (see the module docs in `mod.rs`): every `10**k` +//! materialisation funnels through [`pow10`] (a hard exponent cap plus +//! `check_pow_size`), every Newton/Taylor/argument-reduction loop polls +//! `check_time`, and the correctly-rounded refinement loops (`extra += 3` / +//! `places += 3`) carry a hard round cap. The caps are unreachable for real +//! inputs (bounds are derived from `prec = 28` and the constructor's +//! `DECIMAL_MAX_DIGITS`/exponent limits and documented per call site) — they +//! are insurance against the table-maker's dilemma and contract violations. + +use num_bigint::BigInt; +use num_integer::Integer; +use num_traits::{One, Pow, Signed, ToPrimitive, Zero}; + +use super::{Decimal, EMAX, ETINY, PREC, RoundMode, allocate, check_nans, fix, magnitude_digits}; +use crate::{ + bytecode::VM, + exception_private::{ExcType, RunError, RunResult}, + resource::ResourceTracker, + value::Value, +}; + +/// Hard cap on the exponent of any `10**k` a kernel materialises. The largest +/// legitimate exponent is ≈ 4,700 (the `ln` of a maximal-digit near-1 literal: +/// `places ≤ prec + DECIMAL_MAX_DIGITS + 2 + 3·REFINEMENT_ROUNDS_CAP`; `sqrt`'s +/// base-100 rescale peaks at `10**(2·(DECIMAL_MAX_DIGITS/2 + 1))` ≈ `10**4306`), +/// so 16384 gives >3× headroom while bounding any single kernel `BigInt` at a +/// few kilobytes. Exceeding it means a caller lost its bound — internal error. +const POW10_EXP_CAP: i64 = 16_384; + +/// Hard cap on the correctly-rounded refinement loops (`extra += 3` in `exp`, +/// `places += 3` in `ln`/`log10`, and `_log10_digits`' recompute loop). Each +/// extra round requires ~3 more digits of the (irrational) true result to be +/// `000`/`500`-degenerate — probability ~10⁻³ per round — so even 10 rounds is +/// astronomically unlikely. Insurance against the table-maker's dilemma. +const REFINEMENT_ROUNDS_CAP: u32 = 100; + +/// Hard cap on Newton / argument-reduction iterations (`sqrt`'s Newton loop, +/// [`sqrt_nearest`], `_ilog`'s reduction loop). All converge in well under a +/// hundred iterations for capped-size inputs; unreachable insurance. +const KERNEL_ITERATIONS_CAP: u32 = 10_000; + +/// `len(str((Emax+1)*3))` and `len(str((-Etiny()+1)*3))` under the fixed +/// context — both `len("3000000") == len("3000081") == 7`. An adjusted +/// exponent above this makes `exp` overflow (positive) or underflow to zero +/// (negative), so the shortcuts fire *before* any kernel work: a huge literal +/// exponent (adjusted up to ~±10¹⁸) never reaches `dexp`, keeping all its +/// internal `i64` exponent arithmetic bounded. +const EXP_ADJ_CUTOFF: i64 = 7; + +/// `Decimal.sqrt()` — `_pydecimal` 2681-2778: the square root, correctly +/// rounded via an integer Newton iteration at `prec + 1` digits in base 100. +/// +/// `sqrt(-0)` is `-0` (at half the operand exponent), `sqrt(+Inf)` is `+Inf`, +/// and any other negative operand raises `InvalidOperation`. An exact root +/// comes out at the ideal exponent `⌊exp/2⌋` naturally (the `exact` rescale). +#[expect(clippy::many_single_char_names, reason = "ported line-for-line from _pydecimal")] +pub(super) fn sqrt(d: Decimal, vm: &mut VM<'_, impl ResourceTracker>) -> RunResult { + if d.is_special() { + if let Some(nan) = check_nans(&d, None)? { + return allocate(nan, vm); + } + if d.infinity_sign() == 1 { + return allocate(d, vm); + } + } + if d.is_zero() { + // exponent = self._exp // 2 (floor); sqrt(-0) = -0. + let ans = Decimal::from_triple(d.sign, BigInt::ZERO, d.exp.div_euclid(2)); + return allocate(fix::fix(ans, RoundMode::HalfEven)?, vm); + } + if d.sign == 1 { + return Err(ExcType::decimal_invalid_operation()); + } + + let tracker = vm.heap.tracker(); + // Use an extra digit of precision: prec = context.prec + 1. + let prec = PREC + 1; + + // Write the operand as c·100**e with e = self._exp // 2 the ideal + // exponent; l is the number of base-100 "digits" of c. + let len = i64::try_from(d.digits()).expect("digit count fits i64"); + let mut e = d.exp >> 1; // arithmetic shift = Python floor shift + let (c, l) = if d.exp & 1 != 0 { + (&d.coeff * 10, (len >> 1) + 1) + } else { + (d.coeff, (len + 1) >> 1) + }; + + // Rescale so that c has exactly prec base-100 digits. |shift| is bounded + // by prec and the operand's digit count (l ≤ DECIMAL_MAX_DIGITS/2 + 1), + // so the 100**|shift| = 10**(2·|shift|) below stays ≤ 10**~4306. + let shift = prec - l; + let (c, mut exact) = if shift >= 0 { + (c * pow10(2 * shift, tracker)?, true) + } else { + let (q, remainder) = c.div_mod_floor(&pow10(2 * -shift, tracker)?); + (q, remainder.is_zero()) + }; + e -= shift; + + // n = floor(sqrt(c)) by Newton's method from the over-estimate 10**prec + // (c has exactly prec base-100 digits, so sqrt(c) ∈ [10**(prec-1), 10**prec)). + let mut n = pow10(prec, tracker)?; + let mut iterations = 0u32; + loop { + tracker.check_time()?; + iterations += 1; + if iterations > KERNEL_ITERATIONS_CAP { + return Err(RunError::internal("decimal sqrt did not converge")); + } + let q = c.div_floor(&n); + if n <= q { + break; + } + n = (n + q) >> 1u32; + } + exact = exact && &n * &n == c; + + if exact { + // Result is exact: rescale to use the ideal exponent e. + n = if shift >= 0 { + n / pow10(shift, tracker)? // n % 10**shift == 0 + } else { + n * pow10(-shift, tracker)? + }; + e += shift; + } else if (&n % 5u32).is_zero() { + // Inexact: bump a last digit of 0/5 to 1/6 so the final round to prec + // places is correct under any mode (the "extra digit" trick). + n += 1u32; + } + + // CPython forces ROUND_HALF_EVEN into the final _fix (2773-2776); the + // fixed context's default rounding is already HALF_EVEN. + let ans = Decimal::from_triple(0, n, e); + allocate(fix::fix(ans, RoundMode::HalfEven)?, vm) +} + +/// `Decimal.exp()` — `_pydecimal` 3001-3074: `e**self`, correctly rounded. +/// +/// Overflow/underflow shortcuts fire for any adjusted exponent outside +/// `[-prec-1, 7]` *before* any kernel work (see [`EXP_ADJ_CUTOFF`]), so +/// `Decimal('1E+999999999999').exp()` raises `Overflow` (via `fix`) and its +/// negative twin underflows to `0E-1000026` without a single big allocation. +pub(super) fn exp(d: Decimal, vm: &mut VM<'_, impl ResourceTracker>) -> RunResult { + if let Some(nan) = check_nans(&d, None)? { + return allocate(nan, vm); + } + if d.infinity_sign() == -1 { + return allocate(Decimal::zero(), vm); // exp(-Infinity) = 0 + } + if d.is_zero() { + return allocate(Decimal::from_i64(1), vm); // exp(0) = 1 + } + if d.infinity_sign() == 1 { + return allocate(d, vm); // exp(Infinity) = Infinity + } + + let adj = d.adjusted(); + let ans = if d.sign == 0 && adj > EXP_ADJ_CUTOFF { + // Guaranteed overflow: the sentinel 1E(Emax+1) trips Overflow in fix. + Decimal::from_triple(0, BigInt::one(), EMAX + 1) + } else if d.sign == 1 && adj > EXP_ADJ_CUTOFF { + // Guaranteed underflow: the sentinel 1E(Etiny-1) rounds to zero in fix. + Decimal::from_triple(0, BigInt::one(), ETINY - 1) + } else if d.sign == 0 && adj < -PREC { + // Indistinguishable from 1 at this precision: p+1 digits '1'+'0'*(p-1)+'1' + // so the final round carries the right inexact direction. + Decimal::from_triple(0, pow10(PREC, vm.heap.tracker())? + 1u32, -PREC) + } else if d.sign == 1 && adj < -PREC - 1 { + // Just below 1: p+1 nines. + Decimal::from_triple(0, pow10(PREC + 1, vm.heap.tracker())? - 1u32, -(PREC + 1)) + } else { + // General case: -29 <= adj <= 7, so e below is bounded by the + // operand's digit count (≥ adj - digits + 1 ≥ ~-4330). + let tracker = vm.heap.tracker(); + let c = if d.sign == 1 { -&d.coeff } else { d.coeff.clone() }; + let e = d.exp; + // Increase precision by 3 digits at a time until unambiguously roundable. + let mut extra = 3i64; + let mut rounds = 0u32; + let (coeff, exp) = loop { + tracker.check_time()?; + rounds += 1; + if rounds > REFINEMENT_ROUNDS_CAP { + return Err(RunError::internal("decimal exp did not converge")); + } + let (coeff, exp) = dexp(&c, e, PREC + extra, tracker)?; + if unambiguously_roundable(&coeff, tracker)? { + break (coeff, exp); + } + extra += 3; + }; + Decimal::from_triple(0, coeff, exp) + }; + + // At this stage ans rounds correctly with any mode; CPython forces + // ROUND_HALF_EVEN into _fix (3068-3071) — already our fixed default. + allocate(fix::fix(ans, RoundMode::HalfEven)?, vm) +} + +/// `Decimal.ln()` — `_pydecimal` 3157-3205: the natural logarithm, correctly +/// rounded via the [`dlog`] fixed-point kernel. +/// +/// `ln(±0)` is `-Infinity` (returned directly, no signal — matches the C +/// module), `ln(+Inf)` is `+Infinity`, `ln(1)` is exactly `Decimal('0')`, and +/// any negative operand (including `-Infinity`) raises `InvalidOperation`. +pub(super) fn ln(d: &Decimal, vm: &mut VM<'_, impl ResourceTracker>) -> RunResult { + if let Some(nan) = check_nans(d, None)? { + return allocate(nan, vm); + } + if d.is_zero() { + return allocate(Decimal::infinity(1), vm); + } + if d.infinity_sign() == 1 { + return allocate(Decimal::infinity(0), vm); + } + if is_positive_one(d) { + return allocate(Decimal::zero(), vm); + } + if d.sign == 1 { + return Err(ExcType::decimal_invalid_operation()); + } + + // Result is irrational, hence inexact: repeatedly increase precision by 3 + // until the approximation is unambiguously roundable (at least p+3 places). + let tracker = vm.heap.tracker(); + let mut places = PREC - ln_exp_bound(d, tracker)? + 2; + let mut rounds = 0u32; + let coeff = loop { + tracker.check_time()?; + rounds += 1; + if rounds > REFINEMENT_ROUNDS_CAP { + return Err(RunError::internal("decimal ln did not converge")); + } + let coeff = dlog(&d.coeff, d.exp, places, tracker)?; + if unambiguously_roundable(&coeff, tracker)? { + break coeff; + } + places += 3; + }; + + // CPython forces ROUND_HALF_EVEN into _fix (3199-3203) — our fixed default. + let ans = Decimal::from_triple(u8::from(coeff.is_negative()), coeff.abs(), -places); + allocate(fix::fix(ans, RoundMode::HalfEven)?, vm) +} + +/// `Decimal.log10()` — `_pydecimal` 3237-3286: the base-10 logarithm. +/// +/// Same special cases as [`ln`]; an exact power of ten short-circuits to the +/// integer answer `adjusted()` (which may itself still need rounding). +pub(super) fn log10(d: &Decimal, vm: &mut VM<'_, impl ResourceTracker>) -> RunResult { + if let Some(nan) = check_nans(d, None)? { + return allocate(nan, vm); + } + if d.is_zero() { + return allocate(Decimal::infinity(1), vm); + } + if d.infinity_sign() == 1 { + return allocate(Decimal::infinity(0), vm); + } + if d.sign == 1 { + return Err(ExcType::decimal_invalid_operation()); + } + + let ans = if coeff_is_power_of_ten(d) { + // log10(10**n) = n; the answer may still need rounding in fix. + Decimal::from_i64(d.adjusted()) + } else { + // Irrational, hence inexact: refine until unambiguously roundable. + let tracker = vm.heap.tracker(); + let mut places = PREC - log10_exp_bound(d, tracker)? + 2; + let mut rounds = 0u32; + let coeff = loop { + tracker.check_time()?; + rounds += 1; + if rounds > REFINEMENT_ROUNDS_CAP { + return Err(RunError::internal("decimal log10 did not converge")); + } + let coeff = dlog10(&d.coeff, d.exp, places, tracker)?; + if unambiguously_roundable(&coeff, tracker)? { + break coeff; + } + places += 3; + }; + Decimal::from_triple(u8::from(coeff.is_negative()), coeff.abs(), -places) + }; + + // CPython forces ROUND_HALF_EVEN into _fix (3280-3284) — our fixed default. + allocate(fix::fix(ans, RoundMode::HalfEven)?, vm) +} + +/// Whether `d` equals exactly `Decimal(1)` (`self == _One` in `ln`, 3175): +/// positive, adjusted exponent 0, coefficient of the form `1000…0`. +fn is_positive_one(d: &Decimal) -> bool { + d.sign == 0 && d.adjusted() == 0 && coeff_is_power_of_ten(d) +} + +/// `log10`'s exact-power test (3261): the coefficient digit string is `'1'` +/// followed only by `'0'`s. False for a zero coefficient (`"0"`). +fn coeff_is_power_of_ten(d: &Decimal) -> bool { + let s = d.coeff_str(); + let bytes = s.as_bytes(); + bytes[0] == b'1' && bytes[1..].iter().all(|&b| b == b'0') +} + +/// `Decimal._ln_exp_bound` — `_pydecimal` 3132-3155: a lower bound `r` such +/// that `|self.ln()| >= 10**r`. Requires `self` finite, positive, and `!= 1`. +/// +/// The `adj ∈ {0, -1}` branches materialise `10**-exp` with `-exp` bounded by +/// the operand's digit count (≤ `DECIMAL_MAX_DIGITS`, since `adjusted()` pins +/// `exp` to `-digits + {0,1}` there). The `adj` products use `i128`: `adjusted` +/// can reach ~±10¹⁸, so `adj * 23` would overflow `i64`. +fn ln_exp_bound(d: &Decimal, tracker: &impl ResourceTracker) -> RunResult { + let adj = d.adjusted(); + if adj >= 1 { + // argument >= 10; 23/10 = 2.3 is a lower bound for ln(10). + Ok(decimal_digit_count(i128::from(adj) * 23 / 10) - 1) + } else if adj <= -2 { + // argument <= 0.1 + Ok(decimal_digit_count((-1 - i128::from(adj)) * 23 / 10) - 1) + } else if adj == 0 { + // 1 < self < 10 (self != 1 is a caller precondition, so num > 0). + let num = (&d.coeff - pow10(-d.exp, tracker)?).to_string(); + let den = d.coeff_str(); + // Python compares the digit strings lexicographically; Rust `<` on + // ASCII strings is the same comparison. + Ok(str_len(&num) - str_len(&den) - i64::from(num < den)) + } else { + // adj == -1: 0.1 <= self < 1. + let num = (pow10(-d.exp, tracker)? - &d.coeff).to_string(); + Ok(d.exp + str_len(&num) - 1) + } +} + +/// `Decimal._log10_exp_bound` — `_pydecimal` 3207-3235: a lower bound `r` such +/// that `|self.log10()| >= 10**r`. Requires `self` finite, positive, `!= 1`. +/// +/// `pub(super)` because `__pow__` (2375) uses it for its overflow/underflow +/// bound check (`bound = self._log10_exp_bound() + other.adjusted()`). Same +/// digit-count bounds as [`ln_exp_bound`]. +pub(super) fn log10_exp_bound(d: &Decimal, tracker: &impl ResourceTracker) -> RunResult { + let adj = d.adjusted(); + if adj >= 1 { + // self >= 10 + Ok(decimal_digit_count(i128::from(adj)) - 1) + } else if adj <= -2 { + // self < 0.1 + Ok(decimal_digit_count(i128::from(-1 - adj)) - 1) + } else if adj == 0 { + // 1 < self < 10: |log10(x)| > (1 - 1/x)/2.31. + let num = (&d.coeff - pow10(-d.exp, tracker)?).to_string(); + let den = (&d.coeff * 231i64).to_string(); + Ok(str_len(&num) - str_len(&den) - i64::from(num < den) + 2) + } else { + // adj == -1: 0.1 <= self < 1: |log10(x)| > (1 - x)/2.31. + let num = (pow10(-d.exp, tracker)? - &d.coeff).to_string(); + Ok(str_len(&num) + d.exp - i64::from(num.as_str() < "231") - 1) + } +} + +/// The correctly-rounded refinement test shared by `exp`/`ln`/`log10`: true +/// when `coeff % (5 * 10**(len(str(abs(coeff))) - prec - 1))` is nonzero, +/// i.e. the approximation cannot straddle a rounding boundary at `prec` +/// digits. The cut is bounded by the refinement round (`extra ≤ 3·cap + 1`). +/// +/// CPython asserts `len - p >= 1`; a shorter coefficient (impossible per the +/// kernels' contracts) is treated as roundable so the loop cannot spin. +fn unambiguously_roundable(coeff: &BigInt, tracker: &impl ResourceTracker) -> RunResult { + let cut = magnitude_digits(coeff) - PREC - 1; + if cut < 0 { + Ok(true) + } else { + let modulus = BigInt::from(5) * pow10(cut, tracker)?; + Ok(!coeff.mod_floor(&modulus).is_zero()) + } +} + +/// `_pydecimal._dpower` (5943-5983): given `x = xc·10**xe` (positive, ≠ 1) and +/// `y = yc·10**ye` (nonzero, `yc` sign-carrying), computes `x**y` as `(c, e)` +/// with `10**(p-1) <= c <= 10**p` and an error in `c` of at most 1. +/// +/// Consumed by `__pow__`'s inexact path. The caller's overflow/underflow bound +/// check (`_log10_exp_bound() + other.adjusted() < len(str(Emax))`) keeps +/// `|y·log(x)|` under ~10⁹ before this runs, which bounds every internal +/// exponent; the arithmetic below still uses saturating ops and guarded +/// [`pow10`] so a contract violation degrades to an internal error, never an +/// unbounded allocation. +pub(super) fn dpower( + xc: &BigInt, + xe: i64, + yc: &BigInt, + ye: i64, + p: i64, + tracker: &impl ResourceTracker, +) -> RunResult<(BigInt, i64)> { + // Find b such that 10**(b-1) <= |y| <= 10**b. + let b = magnitude_digits(yc).saturating_add(ye); + + // log(x) = lxc·10**(-p-b-1), to p+b+1 places after the decimal point. + let lxc = dlog(xc, xe, p.saturating_add(b).saturating_add(1), tracker)?; + + // y·log(x) = yc·lxc·10**(-p-b-1+ye) = pc·10**(-p-1); shift = ye - b is + // always -len(str(abs(yc))) (≤ -1, bounded by the operand's digits), but + // the non-negative branch is ported for line fidelity. + let shift = ye.saturating_sub(b); + let pc = if shift >= 0 { + &lxc * yc * pow10(shift, tracker)? + } else { + div_nearest(&(&lxc * yc), &pow10(-shift, tracker)?) + }; + + if pc.is_zero() { + // We prefer a result that isn't exactly 1 (it makes the correctly + // rounded result easier for __pow__): pick the side by whether x**y > 1. + let x_gt_one = magnitude_digits(xc).saturating_add(xe) >= 1; + if x_gt_one == yc.is_positive() { + Ok((pow10(p - 1, tracker)? + 1u32, 1 - p)) + } else { + Ok((pow10(p, tracker)? - 1u32, -p)) + } + } else { + let (coeff, exp) = dexp(&pc, -(p + 1), p + 1, tracker)?; + Ok((div_nearest(&coeff, &BigInt::from(10)), exp + 1)) + } +} + +/// `_pydecimal._log10_lb` (5985-5992): a lower bound for `100 * log10(c)` for +/// a positive integer `c`, from the digit count and a first-digit correction +/// table. `__pow__`'s `_power_exact` (2229) divides by it, so callers must +/// guarantee `c > 1` (for `c == 1` the bound is 0). +pub(super) fn log10_lb(c: &BigInt) -> i64 { + debug_assert!(c.is_positive(), "log10_lb requires a positive argument"); + let s = c.to_string(); + let correction = match s.as_bytes()[0] { + b'1' => 100, + b'2' => 70, + b'3' => 53, + b'4' => 40, + b'5' => 31, + b'6' => 23, + b'7' => 16, + b'8' => 10, + _ => 5, // '9' — positive integers never start with '0' + }; + 100 * str_len(&s) - correction +} + +/// `_pydecimal._decimal_lshift_exact` (5655-5674): `n * 10**e` if that is an +/// integer, else `None`. Used by `_power_exact` to test whether candidate +/// exponents (`e·yc` shifted by `ye`) are integral. +/// +/// Sandbox guard: a positive shift above ~10⁴ returns `None` instead of +/// materialising the power. This is unreachable for any representable result — +/// `__pow__`'s bound check rejects everything with `|log10(x**y)| ≥ ~10⁹` +/// before `_power_exact` runs, and these shifted values *are* result exponents +/// — so the divergence only reroutes impossible inputs to the inexact path. +pub(super) fn decimal_lshift_exact(n: &BigInt, e: i64) -> Option { + /// The defensive positive-shift cap (see above): capped results would have + /// ≥ 10⁴ digits and can never contribute to a representable exact power. + const LSHIFT_EXACT_CAP: i64 = 10_000; + if n.is_zero() { + Some(BigInt::ZERO) + } else if e >= 0 { + (e <= LSHIFT_EXACT_CAP).then(|| n * BigInt::from(10).pow(u32::try_from(e).expect("capped shift fits u32"))) + } else { + // val_n = largest power of 10 dividing n; exact iff it covers -e. + // (-e ≤ val_n ≤ digits ≤ DECIMAL_MAX_DIGITS whenever we divide.) + let digits = n.magnitude().to_string(); + let val_n = str_len(&digits) - str_len(digits.trim_end_matches('0')); + let neg_e = e.checked_neg()?; // e == i64::MIN can never divide exactly + (val_n >= neg_e).then(|| n / BigInt::from(10).pow(u32::try_from(neg_e).expect("bounded by val_n"))) + } +} + +/// `_pydecimal._sqrt_nearest` (5676-5689): the closest integer to `sqrt(n)` +/// for positive `n`, by Newton's method from the initial over-estimate `a`. +/// +/// Python's update `a--n//a>>1` parses as `(a - ((-n) // a)) >> 1`, i.e. +/// `(a + ceil(n/a)) >> 1` — the `(-n) // a` floor division is what makes the +/// iterate round *up*, which the convergence proof relies on. Non-positive +/// arguments are a caller bug (CPython raises `ValueError`): internal error. +pub(super) fn sqrt_nearest(n: &BigInt, a: &BigInt, tracker: &impl ResourceTracker) -> RunResult { + if !n.is_positive() || !a.is_positive() { + return Err(RunError::internal("decimal sqrt_nearest requires positive arguments")); + } + let mut a = a.clone(); + let mut b = BigInt::ZERO; + let mut iterations = 0u32; + while a != b { + tracker.check_time()?; + iterations += 1; + if iterations > KERNEL_ITERATIONS_CAP { + return Err(RunError::internal("decimal sqrt_nearest did not converge")); + } + // (a + ceil(n/a)) >> 1 — the shifted value is positive, so the shift + // needs no floor-semantics care. + let next = (&a - (-n).div_floor(&a)) >> 1u32; + b = a; + a = next; + } + Ok(a) +} + +/// `_pydecimal._rshift_nearest` (5691-5697): the closest integer to +/// `x / 2**shift` (`shift >= 0`), ties rounded to even. +/// +/// Expressed via `div_floor`/`mod_floor` on `2**shift` rather than `>>`/`&` so +/// Python's floor-shift and two's-complement-mask semantics for negative `x` +/// hold by construction (`x & (b-1) == x.mod_floor(b)`, `x >> s == x.div_floor(b)`). +pub(super) fn rshift_nearest(x: &BigInt, shift: u64) -> BigInt { + let b = BigInt::one() << shift; + let q = x.div_floor(&b); + // q + (2*(x & (b-1)) + (q&1) > b); q&1 is q.mod_floor(2), i.e. is_odd. + let tie = x.mod_floor(&b) * 2 + u32::from(q.is_odd()); + if tie > b { q + 1u32 } else { q } +} + +/// `_pydecimal._div_nearest` (5699-5705): the closest integer to `a / b` for +/// positive `b`, ties rounded to even. +/// +/// The docstring in `_pydecimal` claims both operands positive, but the +/// kernels routinely pass a negative `a` (Taylor terms, `yc·lxc` products); +/// Python's `divmod` is floor-based, so `div_floor`/`mod_floor` reproduce it +/// exactly (`r ∈ [0, b)` and `q & 1 == q.is_odd()` for negative `q` too). +pub(super) fn div_nearest(a: &BigInt, b: &BigInt) -> BigInt { + debug_assert!(b.is_positive(), "div_nearest requires a positive divisor"); + let (q, r) = a.div_mod_floor(b); + let tie = r * 2 + u32::from(q.is_odd()); + if tie > *b { q + 1u32 } else { q } +} + +/// `_pydecimal._dexp` (5907-5941): an approximation to `exp(c·10**e)` with `p` +/// decimal digits of precision, returned as `(d, f)` with +/// `10**(p-1) <= d <= 10**p` and error in `d` at most 1. +/// +/// Exponent arithmetic is bounded by the callers: `exp`'s shortcuts pin the +/// operand's adjusted exponent to `[-prec-2, 7]` (so `e ∈ [~-4630, 8]` and the +/// quotient below is < ~10⁸), and `dpower` passes `e = -(p+1)` with `|pc|` +/// bounded by `__pow__`'s overflow check. A quotient outside `i64` therefore +/// means a broken caller contract — internal error, never a bad allocation. +/// (Line fidelity note: CPython's `len(str(c))` in the `extra` computation +/// counts the `'-'` of a negative `c`, harmlessly adding one digit of margin; +/// reproduced here.) +fn dexp(c: &BigInt, e: i64, p: i64, tracker: &impl ResourceTracker) -> RunResult<(BigInt, i64)> { + // We'll call iexp with M = 10**(p+2), giving p+3 digits of precision. + let p = p.saturating_add(2); + + // Extra precision for log(10) = the adjusted exponent of c·10**e. + let signed_len = str_len(&c.to_string()); + let extra = (e.saturating_add(signed_len) - 1).max(0); + let q = p.saturating_add(extra); + + // Quotient c·10**(e+q) / (log(10)·10**q), rounding down (floor: c may be + // negative). |shift| ≤ |e| + p + extra, all bounded per the doc above. + let shift = e.saturating_add(q); + let cshift = if shift >= 0 { + c * pow10(shift, tracker)? + } else { + c.div_floor(&pow10(-shift, tracker)?) + }; + let (quot, rem) = cshift.div_mod_floor(&log10_digits(q, tracker)?); + + // Reduce the remainder back to the original precision. + let rem = div_nearest(&rem, &pow10(extra, tracker)?); + + // Error in the result of iexp < 120; error after the division < 0.62. + let coeff = div_nearest(&iexp(&rem, &pow10(p, tracker)?, tracker)?, &BigInt::from(1000)); + let quot = quot + .to_i64() + .ok_or_else(|| RunError::internal("decimal dexp exponent out of bounds"))?; + Ok((coeff, quot - p + 3)) +} + +/// `_pydecimal._iexp` (5870-5905): an integer approximation to `M·exp(x/M)` +/// for `0 <= x/M <= 2.4`, with absolute error at most 60 — argument halving +/// (`R` doublings) plus a Taylor series for `expm1`, all in fixed point. +/// +/// The contract bounds `R` at ~10 (`R = nbits((x << 8) // M)`); a larger `R` +/// means the caller broke the `x/M` bound — internal error. +#[expect(clippy::many_single_char_names, reason = "ported line-for-line from _pydecimal")] +fn iexp(x: &BigInt, m: &BigInt, tracker: &impl ResourceTracker) -> RunResult { + const L: u32 = 8; + + // Find R such that x / 2**R / M <= 2**-L. + let r = ((x << L) / m).bits(); + let r = u32::try_from(r) + .ok() + .filter(|&r| r <= 64) + .ok_or_else(|| RunError::internal("decimal iexp argument out of range"))?; + + // Taylor series with T terms: (2**L)**T > M. The explicit ceiling avoids + // the `Integer::div_ceil` name collision: -int(-10*len(str(M))//(3*L)). + let t = (10 * magnitude_digits(m) + 23) / 24; + let mut y = div_nearest(x, &BigInt::from(t)); + let mshift = m << r; + for i in (1..t).rev() { + tracker.check_time()?; + y = div_nearest(&(x * (&mshift + &y)), &(&mshift * i)); + } + + // Expansion: expm1(2z) = expm1(z)·(expm1(z) + 2), R times. + for k in (0..r).rev() { + tracker.check_time()?; + let mshift = m << (k + 2); + y = div_nearest(&(&y * (&y + &mshift)), &mshift); + } + + Ok(m + y) +} + +/// `_pydecimal._dlog` (5789-5831): an integer approximation to +/// `10**p · log(c·10**e)` for `c > 0`, absolute error at most 1. +/// +/// `k = e + p - f` collapses algebraically to `p - l + (0|1)` (computed that +/// way to avoid an `e + p` overflow for saturated `p`), so the `10**|k|` +/// materialisation is bounded by `p` and the operand's digit count; the `f` +/// term's `10**extra` is bounded by `extra ≤ 18` (`f` fits `i64`). +#[expect(clippy::many_single_char_names, reason = "ported line-for-line from _pydecimal")] +fn dlog(c: &BigInt, e: i64, p: i64, tracker: &impl ResourceTracker) -> RunResult { + // Increase precision by 2; compensated by the final division by 100. + let p = p.saturating_add(2); + + // Rewrite c·10**e as d·10**f with f >= 0 and 1 <= d <= 10, or f <= 0 and + // 0.1 <= d <= 1: 10**p·log(c·10**e) = 10**p·log(d) + 10**p·f·log(10). + let l = magnitude_digits(c); + let f = e + l - i64::from(e + l >= 1); + + // Approximation to 10**p·log(d), error < 27 (ilog magnifies the ≤ 0.5 + // rescale error in c by at most 10: 5 + 22). + let log_d = if p > 0 { + let k = p - l + i64::from(e + l >= 1); // == e + p - f + let scaled = if k >= 0 { + c * pow10(k, tracker)? + } else { + div_nearest(c, &pow10(-k, tracker)?) + }; + ilog(&scaled, &pow10(p, tracker)?, tracker)? + } else { + // p <= 0: approximate log(d) by 0; error < 2.31. + BigInt::ZERO + }; + + // Approximation to f·10**p·log(10), error < 11. + let f_log_ten = if f != 0 { + let extra = str_len(&f.unsigned_abs().to_string()) - 1; + if p.saturating_add(extra) >= 0 { + // Error in f·log10_digits(p+extra) < |f|; after division + // < |f|/10**extra + 0.5 < 10.5 < 11. + div_nearest(&(log10_digits(p + extra, tracker)? * f), &pow10(extra, tracker)?) + } else { + BigInt::ZERO + } + } else { + BigInt::ZERO + }; + + // Error in the sum < 38; after the division by 100 < 0.38 + 0.5 < 1. + Ok(div_nearest(&(f_log_ten + log_d), &BigInt::from(100))) +} + +/// `_pydecimal._dlog10` (5755-5787): an integer approximation to +/// `10**p · log10(c·10**e)` for `c > 0`, absolute error at most 1. +/// +/// Only called from [`log10`], whose `places` is always ≥ 12 (see +/// [`log10_exp_bound`]), so the `p <= 0` branch — whose `10**-p` would be +/// exponent-derived — is unreachable; [`pow10`]'s guard covers it regardless. +#[expect(clippy::many_single_char_names, reason = "ported line-for-line from _pydecimal")] +fn dlog10(c: &BigInt, e: i64, p: i64, tracker: &impl ResourceTracker) -> RunResult { + // Increase precision by 2; compensated by the final division by 100. + let p = p.saturating_add(2); + + // As in dlog: c·10**e = d·10**f with d near 1, so that + // 10**p·log10(c·10**e) = 10**p·(f + log10(d)). + let l = magnitude_digits(c); + let f = e + l - i64::from(e + l >= 1); + + if p > 0 { + let m = pow10(p, tracker)?; + let k = p - l + i64::from(e + l >= 1); // == e + p - f, bounded by p and digits + let scaled = if k >= 0 { + c * pow10(k, tracker)? + } else { + div_nearest(c, &pow10(-k, tracker)?) + }; + let log_d = ilog(&scaled, &m, tracker)?; // error < 5 + 22 = 27 + let log_10 = log10_digits(p, tracker)?; // error < 1 + let log_d = div_nearest(&(log_d * &m), &log_10); + let log_tenpower = &m * f; // exact + Ok(div_nearest(&(log_tenpower + log_d), &BigInt::from(100))) + } else { + // log_d = 0, error < 2.31; f/10**-p, error < 0.5. + let log_tenpower = div_nearest(&BigInt::from(f), &pow10(p.saturating_neg(), tracker)?); + Ok(div_nearest(&log_tenpower, &BigInt::from(100))) + } +} + +/// `_pydecimal._ilog` (5707-5753): an integer approximation to `M·log(x/M)` +/// with error at most 22 for `0.1 <= x/M <= 10` — repeated +/// `log1p(y) = 2·log1p(y/(1+sqrt(1+y)))` argument reduction, then a Taylor +/// series, in fixed point scaled by `M` (with `y` scaled by `2**R·M`). +/// +/// The reduction count is ~`L + log2(log(x/M))` ≈ 12 for in-contract inputs; +/// the Taylor term count `T = ceil(10·len(str(M))/24)` is bounded by `M`'s +/// digits (≤ [`POW10_EXP_CAP`]). Both loops poll `check_time`. +#[expect(clippy::many_single_char_names, reason = "ported line-for-line from _pydecimal")] +fn ilog(x: &BigInt, m: &BigInt, tracker: &impl ResourceTracker) -> RunResult { + const L: u64 = 8; + + let mut y = x - m; + // Argument reduction; r = number of reductions performed so far. + let mut r: u64 = 0; + let mut iterations = 0u32; + loop { + tracker.check_time()?; + let reduce = if r <= L { + (y.abs() << (L - r)) >= *m + } else { + // y.abs() is non-negative, so the shift is floor regardless. + (y.abs() >> (r - L)) >= *m + }; + if !reduce { + break; + } + iterations += 1; + if iterations > KERNEL_ITERATIONS_CAP { + return Err(RunError::internal("decimal ilog did not converge")); + } + let root = sqrt_nearest(&(m * (m + rshift_nearest(&y, r))), m, tracker)?; + y = div_nearest(&((m * &y) << 1u32), &(m + root)); + r += 1; + } + + // Taylor series with T terms: log1p(y) ~ y - y²/2 + y³/3 - … The explicit + // ceiling avoids the `Integer::div_ceil` collision: -int(-10*len(str(M))//(3*L)). + let t = (10 * magnitude_digits(m) + 23) / 24; + let yshift = rshift_nearest(&y, r); + let mut w = div_nearest(m, &BigInt::from(t)); + for k in (1..t).rev() { + tracker.check_time()?; + w = div_nearest(m, &BigInt::from(k)) - div_nearest(&(&yshift * &w), m); + } + + Ok(div_nearest(&(w * y), m)) +} + +/// The first 47 digits of `log(10) = 2.302585…` — `_pydecimal._Log10Memoize`'s +/// seed (5838), copied verbatim. Covers every request with `p + 1 <= 47`, +/// which includes all first-round prec-28 computations (`p ≈ 30-40`). +const LOG10_DIGITS_SEED: &str = "23025850929940456840179914546843642076011014886"; + +/// `_pydecimal._log10_digits` (5833-5868): `floor(10**p · log(10))`. +/// +/// Deliberately **no** mutable memo (CPython's `_Log10Memoize` grows a global +/// string): a process-global cache would grow without bound under attacker +/// control and make execution state depend on prior runs, breaking snapshot +/// determinism. Instead the seed constant serves `p <= 46` and larger `p` +/// (only reachable via many-digit operands or deep refinement rounds, bounded +/// by [`POW10_EXP_CAP`]) recompute from `_ilog(10·M, M)` per call. +fn log10_digits(p: i64, tracker: &impl ResourceTracker) -> RunResult { + if p < 0 { + return Err(RunError::internal("decimal log10_digits requires p >= 0")); + } + let want = usize::try_from(p + 1).expect("p+1 fits usize"); + if want <= LOG10_DIGITS_SEED.len() { + return Ok(parse_digits(&LOG10_DIGITS_SEED[..want])); + } + + // Compute p+extra digits, correct to within 1 ulp, until at least one of + // the extra digits is nonzero (so truncating to p+1 digits is reliable). + let mut extra = 3i64; + let mut rounds = 0u32; + loop { + tracker.check_time()?; + rounds += 1; + if rounds > REFINEMENT_ROUNDS_CAP { + return Err(RunError::internal("decimal log10_digits did not converge")); + } + let m = pow10(p + extra + 2, tracker)?; + let digits = div_nearest(&ilog(&(&m * 10), &m, tracker)?, &BigInt::from(100)).to_string(); + let tail = usize::try_from(extra).expect("extra fits usize"); + if digits.len() > tail && !digits[digits.len() - tail..].bytes().all(|b| b == b'0') { + // CPython memoises `digits.rstrip('0')[:-1]` (≥ p+1 reliable + // chars) and returns its first p+1 digits — identical to slicing + // the computed string directly. + return digits + .get(..want) + .map(parse_digits) + .ok_or_else(|| RunError::internal("decimal log10_digits produced too few digits")); + } + extra += 3; + } +} + +/// Parses an ASCII digit string into a `BigInt` (kernel-internal slices only). +fn parse_digits(s: &str) -> BigInt { + BigInt::parse_bytes(s.as_bytes(), 10).expect("kernel digit slice is ASCII digits") +} + +/// `10**exp` under the kernels' extra guard: the hard [`POW10_EXP_CAP`] (a +/// breached cap means a caller lost its documented bound — internal error) on +/// top of the shared tracker-checked [`super`] `pow10`. All kernel powers of +/// ten funnel through here. +fn pow10(exp: i64, tracker: &impl ResourceTracker) -> RunResult { + if !(0..=POW10_EXP_CAP).contains(&exp) { + return Err(RunError::internal("decimal power-of-ten exponent out of bounds")); + } + super::pow10(exp, tracker) +} + +/// A string's length as the `i64` the exponent arithmetic works in. +fn str_len(s: &str) -> i64 { + i64::try_from(s.len()).expect("string length fits i64") +} + +/// Python's `len(str(v))` for a non-negative `i128` — the exponent-bound +/// estimates multiply `adjusted()` (up to ~10¹⁸) by 23, which needs `i128`. +fn decimal_digit_count(v: i128) -> i64 { + debug_assert!(v >= 0, "digit count of a non-negative value"); + str_len(&v.to_string()) +} diff --git a/crates/monty/src/types/list.rs b/crates/monty/src/types/list.rs index c068f1f44..5d84d1783 100644 --- a/crates/monty/src/types/list.rs +++ b/crates/monty/src/types/list.rs @@ -484,7 +484,7 @@ impl<'h> PyTrait<'h> for HeapRead<'h, List> { repr_sequence_fmt('[', ']', len, |heap, i| &self.get(heap).as_slice()[i], f, vm, heap_ids) } - fn py_add(&self, other: &Self, vm: &mut VM<'h, impl ResourceTracker>) -> Result, ResourceError> { + fn py_add(&self, other: &Self, vm: &mut VM<'h, impl ResourceTracker>) -> RunResult> { let mut items = self.clone_all_items(vm); items.extend(other.clone_all_items(vm)); let id = vm.heap.allocate(HeapData::List(List::new(items)))?; diff --git a/crates/monty/src/types/long_int.rs b/crates/monty/src/types/long_int.rs index 05f0df1ce..a22f4ac25 100644 --- a/crates/monty/src/types/long_int.rs +++ b/crates/monty/src/types/long_int.rs @@ -268,16 +268,22 @@ pub fn check_bigint_str_digits_limit(value: &BigInt) -> RunResult<()> { Ok(()) } +/// A strict upper bound on the decimal digit count of an integer with the +/// given bit count: `bits · log10(2)` rounded up via the `30_103/100_000` +/// over-approximation of `log10(2) ≈ 0.301029995…`, plus one. Shared by the +/// `int` → `str` preflight below and the `Decimal` constructor's digit cap. +#[must_use] +pub(crate) fn estimate_decimal_digits(bits: u64) -> u64 { + bits.saturating_mul(30_103) / 100_000 + 1 +} + /// Checks whether an integer with the given bit count might exceed the decimal /// digit limit when converted to a string. /// /// This remains as a cheap preflight helper for code that only needs a fast /// upper-bound check and does not require the exact boundary behavior. pub fn check_bits_str_digits_limit(bits: u64) -> RunResult<()> { - // log10(2) ≈ 0.30103 = 30_103/100_000 - // estimated_digits is an upper bound on the actual decimal digit count. - let estimated_digits = bits.saturating_mul(30_103) / 100_000 + 1; - if estimated_digits > INT_MAX_STR_DIGITS as u64 { + if estimate_decimal_digits(bits) > INT_MAX_STR_DIGITS as u64 { return Err(ExcType::value_error_int_too_large_for_str()); } Ok(()) diff --git a/crates/monty/src/types/mod.rs b/crates/monty/src/types/mod.rs index 44c8dafd2..2c06688f4 100644 --- a/crates/monty/src/types/mod.rs +++ b/crates/monty/src/types/mod.rs @@ -10,6 +10,7 @@ pub mod class; pub mod dataclass; pub mod date; pub mod datetime; +pub mod decimal; pub mod dict; pub mod dict_view; pub mod file; diff --git a/crates/monty/src/types/py_trait.rs b/crates/monty/src/types/py_trait.rs index 5dbc7dc72..17b9c91b5 100644 --- a/crates/monty/src/types/py_trait.rs +++ b/crates/monty/src/types/py_trait.rs @@ -262,16 +262,18 @@ pub(crate) trait PyTrait<'h> { /// Python addition (`__add__`). /// /// Returns `Ok(None)` if the operation is not supported for these types, - /// `Ok(Some(value))` on success, or `Err(ResourceError)` if allocation fails. - fn py_add(&self, _other: &Self, _vm: &mut VM<'h, impl ResourceTracker>) -> Result, ResourceError> { + /// `Ok(Some(value))` on success, or `Err(RunError)` if an error occurs + /// (allocation failure, or a `decimal` exception such as `inf - inf`). + fn py_add(&self, _other: &Self, _vm: &mut VM<'h, impl ResourceTracker>) -> RunResult> { Ok(None) } /// Python subtraction (`__sub__`). /// /// Returns `Ok(None)` if the operation is not supported for these types, - /// `Ok(Some(value))` on success, or `Err(ResourceError)` if allocation fails. - fn py_sub(&self, _other: &Self, _vm: &mut VM<'h, impl ResourceTracker>) -> Result, ResourceError> { + /// `Ok(Some(value))` on success, or `Err(RunError)` if an error occurs + /// (allocation failure, or a `decimal` exception such as `inf - inf`). + fn py_sub(&self, _other: &Self, _vm: &mut VM<'h, impl ResourceTracker>) -> RunResult> { Ok(None) } diff --git a/crates/monty/src/types/str.rs b/crates/monty/src/types/str.rs index 233c5f8f4..881e0f383 100644 --- a/crates/monty/src/types/str.rs +++ b/crates/monty/src/types/str.rs @@ -262,7 +262,7 @@ impl<'h> PyTrait<'h> for HeapRead<'h, Str> { Ok(allocate_string(self.get(vm.heap).as_str(), vm.heap)?) } - fn py_add(&self, other: &Self, vm: &mut VM<'h, impl ResourceTracker>) -> Result, ResourceError> { + fn py_add(&self, other: &Self, vm: &mut VM<'h, impl ResourceTracker>) -> RunResult> { Ok(Some(concat_allocate_str( self.get(vm.heap).as_str(), other.get(vm.heap).as_str(), diff --git a/crates/monty/src/types/tuple.rs b/crates/monty/src/types/tuple.rs index 0ed81af89..9ce6c37c2 100644 --- a/crates/monty/src/types/tuple.rs +++ b/crates/monty/src/types/tuple.rs @@ -414,7 +414,7 @@ impl<'h> PyTrait<'h> for HeapRead<'h, Tuple> { Ok(CmpOrder::Ordered(a_len.cmp(&b_len))) } - fn py_add(&self, other: &Self, vm: &mut VM<'h, impl ResourceTracker>) -> Result, ResourceError> { + fn py_add(&self, other: &Self, vm: &mut VM<'h, impl ResourceTracker>) -> RunResult> { let mut items = self.clone_all_items(vm); items.extend(other.clone_all_items(vm)); Ok(Some(allocate_tuple(items, vm.heap)?)) diff --git a/crates/monty/src/types/type.rs b/crates/monty/src/types/type.rs index 74c3bd3dc..fe44d290a 100644 --- a/crates/monty/src/types/type.rs +++ b/crates/monty/src/types/type.rs @@ -12,7 +12,7 @@ use crate::{ resource::ResourceTracker, types::{ AttrCallResult, Bytes, Dict, FrozenSet, List, LongInt, MontyIter, Path, PyTrait, Range, Set, Slice, Str, - TimeZone, Tuple, bytes::bytes_fromhex, date, datetime, dict::dict_fromkeys, instance::class_name, + TimeZone, Tuple, bytes::bytes_fromhex, date, datetime, decimal, dict::dict_fromkeys, instance::class_name, long_int::INT_MAX_STR_DIGITS, str::StringRepr, timedelta, }, value::Value, @@ -129,6 +129,11 @@ pub enum Type { /// A regex match result from `re.match()` / `re.search()` etc. - displays as "re.Match" #[strum(serialize = "re.Match")] ReMatch, + /// A `decimal.Decimal` value. Appended at the enum end: `Type` is embedded + /// in postcard snapshots, which encode variants by index, so a mid-enum + /// insertion would shift every later variant. + #[strum(serialize = "decimal.Decimal")] + Decimal, } /// Writes the canonical static name of every non-[`Instance`](Type::Instance) @@ -386,6 +391,7 @@ impl Type { Self::DateTime => datetime::init(vm, args), Self::TimeDelta => timedelta::init(vm, args), Self::TimeZone => TimeZone::init(vm, args), + Self::Decimal => decimal::init(vm, args), Self::Iterator => MontyIter::init(vm, args), Self::Path => Path::init(vm, args), @@ -404,6 +410,7 @@ impl Type { Value::Ref(heap_id) => match vm.heap.get(*heap_id) { HeapData::Str(s) => parse_int_from_str(s.as_str(), vm.heap), HeapData::LongInt(_) => Ok(v.clone_with_heap(vm.heap)), + HeapData::Decimal(d) => decimal::to_int(&d.clone(), vm), _ => Err(ExcType::type_error_int_conversion(&v.py_type_name(vm))), }, _ => Err(ExcType::type_error_int_conversion(&v.py_type_name(vm))), @@ -424,6 +431,7 @@ impl Type { } Value::Ref(heap_id) => match vm.heap.get(*heap_id) { HeapData::Str(s) => Ok(Value::Float(parse_f64_from_str(s.as_str())?)), + HeapData::Decimal(d) => decimal::to_float(d).map(Value::Float), _ => Err(ExcType::type_error_float_conversion(&v.py_type_name(vm))), }, _ => Err(ExcType::type_error_float_conversion(&v.py_type_name(vm))), diff --git a/crates/monty/src/value.rs b/crates/monty/src/value.rs index aceeb6520..2d5339e98 100644 --- a/crates/monty/src/value.rs +++ b/crates/monty/src/value.rs @@ -1,3 +1,5 @@ +#[cfg(feature = "memory-model-checks")] +use std::cell::Cell; use std::{ borrow::Cow, cmp::Ordering, @@ -11,6 +13,7 @@ use std::{ use num_bigint::BigInt; use num_integer::Integer; use num_traits::{FromPrimitive, ToPrimitive, Zero}; +use serde::de::DeserializeOwned; use smallvec::SmallVec; use crate::{ @@ -30,6 +33,7 @@ use crate::{ types::{ Bytes, CmpOrder, LazyHeapSet, List, LongInt, Property, PyTrait, Type, allocate_tuple, bytes::{bytes_repr_fmt, get_byte_at_index}, + decimal, instance::{instance_getattr, instance_repr, instance_str}, long_int::{bigint_cmp_f64, check_bits_str_digits_limit, i64_cmp_f64}, path, @@ -105,13 +109,77 @@ pub(crate) enum Value { /// Must match the per-element unit used by `py_estimate_size` implementations. pub(crate) const VALUE_SIZE: usize = mem::size_of::(); +/// Deserializes a snapshot (`postcard`) with the `Value::Ref` drop-panic +/// suppressed for the duration of the load — and only for that duration. +/// +/// Snapshot bytes are untrusted and may be *rejected partway* — e.g. a +/// `Decimal` whose canonical string is corrupt or over the digit cap fails to +/// deserialize (see `decimal::Decimal`'s `Deserialize`). When that happens, the +/// partially-built `Value::Ref`s already deserialized into `globals` / `stack` / +/// heap entries unwind and drop with no heap to route through. That is **not** a +/// reference-counting bug — the whole partial state is discarded — so it must +/// not trip the `memory-model-checks` guard in [`Value`]'s `Drop`. Every +/// snapshot `load` entry point goes through here so the suppression is uniform. +/// +/// The suppression cannot be narrowed below the whole deserialization pass: +/// refs materialize *during* the pass and the rejection unwinds through +/// `postcard`, so there is no per-entry boundary to scope the guard to. The +/// cost is that a refcount bug inside a `Deserialize` impl would go undetected +/// during loads specifically; everything after `load` returns is fully +/// guarded (an unresumed snapshot's teardown is handled by `VMSnapshot`'s own +/// scoped `Drop`, not by this window). +pub(crate) fn from_snapshot_bytes(bytes: &[u8]) -> Result { + #[cfg(feature = "memory-model-checks")] + let _suppress = RefDropPanicSuppressor::new(); + postcard::from_bytes(bytes) +} + +// When set, `Value`'s `Drop` does not panic on a bare `Ref`. Toggled by +// `RefDropPanicSuppressor` around snapshot deserialization +// (`from_snapshot_bytes`) and around self-contained-snapshot teardown +// (`VMSnapshot`'s `memory-model-checks` `Drop`). +#[cfg(feature = "memory-model-checks")] +thread_local! { + static SUPPRESS_REF_DROP_PANIC: Cell = const { Cell::new(false) }; +} + +/// RAII guard that suppresses the `Value::Ref` drop-panic for its lifetime. +/// Saves and restores the previous flag on drop so nested scopes compose. Only +/// present under `memory-model-checks`, with exactly two construction sites: +/// [`from_snapshot_bytes`] (a rejected load unwinds bare refs) and +/// `VMSnapshot`'s `Drop` (an unresumed snapshot's refs die with the heap +/// serialized alongside them) — see each site for why the suppression is +/// sound there. +#[cfg(feature = "memory-model-checks")] +pub(crate) struct RefDropPanicSuppressor(bool); + +#[cfg(feature = "memory-model-checks")] +impl RefDropPanicSuppressor { + pub(crate) fn new() -> Self { + Self(SUPPRESS_REF_DROP_PANIC.with(|c| c.replace(true))) + } +} + +#[cfg(feature = "memory-model-checks")] +impl Drop for RefDropPanicSuppressor { + fn drop(&mut self) { + SUPPRESS_REF_DROP_PANIC.with(|c| c.set(self.0)); + } +} + /// Drop implementation that panics if a `Ref` variant is dropped without calling `drop_with_heap`. /// This helps catch reference counting bugs during development/testing. /// Only enabled when the `memory-model-checks` feature is active. +/// +/// The panic is suppressed during snapshot deserialization (see +/// [`from_snapshot_bytes`]), where a rejected load legitimately unwinds bare +/// `Ref`s that were never assembled into a heap. #[cfg(feature = "memory-model-checks")] impl Drop for Value { fn drop(&mut self) { - if let Self::Ref(id) = self { + if let Self::Ref(id) = self + && !SUPPRESS_REF_DROP_PANIC.with(Cell::get) + { panic!("Value::Ref({id:?}) dropped without calling drop_with_heap() - this is a reference counting bug"); } } @@ -255,6 +323,17 @@ impl<'h> PyTrait<'h> for Value { (Self::Ref(id), Self::Float(o)) if let HeapData::LongInt(li) = vm.heap.get(*id) => { Ok(CmpOrder::from_numeric(li.partial_cmp_f64(*o))) } + // Decimal vs any number, exact ordering. Ordering against a NaN + // raises `InvalidOperation` (handled inside `cmp_value`, so a + // `None` from it can only mean a non-number operand → + // `Incomparable` → the ordering TypeError); the right-operand arm + // reverses since the helper compares from the Decimal's side. + (Self::Ref(id), other) if let HeapData::Decimal(d) = vm.heap.get(*id) => { + Ok(CmpOrder::from_total(decimal::cmp_value(d, other, vm.heap, false)?)) + } + (other, Self::Ref(id)) if let HeapData::Decimal(d) = vm.heap.get(*id) => { + Ok(CmpOrder::from_total(decimal::cmp_value(d, other, vm.heap, true)?)) + } // Ref vs Ref comparison: handles LongInt, Str, Tuple, and List (Self::Ref(id1), Self::Ref(id2)) => match (vm.heap.read(*id1), vm.heap.read(*id2)) { (HeapReadOutput::LongInt(a), HeapReadOutput::LongInt(b)) => { @@ -416,7 +495,14 @@ impl<'h> PyTrait<'h> for Value { } } - fn py_add(&self, other: &Self, vm: &mut VM<'_, impl ResourceTracker>) -> Result, ResourceError> { + fn py_add(&self, other: &Self, vm: &mut VM<'_, impl ResourceTracker>) -> RunResult> { + // Decimal probe first: captures any Decimal operand before the + // generic `Ref`-inspecting arms below could; yields `None` for + // non-Decimal pairs and for operands Decimal rejects (e.g. float), + // which fall through to the usual dispatch / TypeError. + if let Some(result) = decimal::binary_op_value(self, other, decimal::BinOp::Add, vm)? { + return Ok(Some(result)); + } let interns = vm.interns; match (self, other) { // Int + Int with overflow detection @@ -426,7 +512,7 @@ impl<'h> PyTrait<'h> for Value { } else { // Overflow - promote to LongInt let li = LongInt::from(*a) + LongInt::from(*b); - li.into_value(vm.heap).map(Some) + Ok(Some(li.into_value(vm.heap)?)) } } // Int + LongInt @@ -434,7 +520,7 @@ impl<'h> PyTrait<'h> for Value { if let HeapData::LongInt(li) = vm.heap.get(*id) => { let result = LongInt::new(li.inner() + i); - result.into_value(vm.heap).map(Some) + Ok(Some(result.into_value(vm.heap)?)) } (Self::Float(v1), Self::Float(v2)) => Ok(Some(Self::Float(v1 + v2))), // Int + Float and Float + Int @@ -443,7 +529,7 @@ impl<'h> PyTrait<'h> for Value { (Self::Ref(id1), Self::Ref(id2)) => { let left = vm.heap.read(*id1); let right = vm.heap.read(*id2); - left.py_add(&right, vm) + Ok(left.py_add(&right, vm)?) } (Self::InternString(s1), Self::InternString(s2)) => Ok(Some(concat_allocate_str( interns.get_str(*s1), @@ -484,7 +570,14 @@ impl<'h> PyTrait<'h> for Value { } } - fn py_sub(&self, other: &Self, vm: &mut VM<'_, impl ResourceTracker>) -> Result, ResourceError> { + fn py_sub(&self, other: &Self, vm: &mut VM<'_, impl ResourceTracker>) -> RunResult> { + // Decimal probe first: captures any Decimal operand before the + // generic `Ref`-inspecting arms below could; yields `None` for + // non-Decimal pairs and for operands Decimal rejects (e.g. float), + // which fall through to the usual dispatch / TypeError. + if let Some(result) = decimal::binary_op_value(self, other, decimal::BinOp::Sub, vm)? { + return Ok(Some(result)); + } match (self, other) { // Int - Int with overflow detection (Self::Int(a), Self::Int(b)) => { @@ -493,24 +586,24 @@ impl<'h> PyTrait<'h> for Value { } else { // Overflow - promote to LongInt let li = LongInt::from(*a) - LongInt::from(*b); - li.into_value(vm.heap).map(Some) + Ok(Some(li.into_value(vm.heap)?)) } } // Int - LongInt (Self::Int(a), Self::Ref(id)) if let HeapData::LongInt(li) = vm.heap.get(*id) => { let result = LongInt::from(*a) - LongInt::new(li.inner().clone()); - result.into_value(vm.heap).map(Some) + Ok(Some(result.into_value(vm.heap)?)) } // LongInt - Int (Self::Ref(id), Self::Int(b)) if let HeapData::LongInt(li) = vm.heap.get(*id) => { let result = LongInt::new(li.inner().clone()) - LongInt::from(*b); - result.into_value(vm.heap).map(Some) + Ok(Some(result.into_value(vm.heap)?)) } // LongInt - LongInt (Self::Ref(id1), Self::Ref(id2)) => { let left = vm.heap.read(*id1); let right = vm.heap.read(*id2); - left.py_sub(&right, vm) + Ok(left.py_sub(&right, vm)?) } // Float - Float (Self::Float(a), Self::Float(b)) => Ok(Some(Self::Float(a - b))), @@ -522,6 +615,13 @@ impl<'h> PyTrait<'h> for Value { } fn py_mod(&self, other: &Self, vm: &mut VM<'_, impl ResourceTracker>) -> RunResult> { + // Decimal probe first: captures any Decimal operand before the + // generic `Ref`-inspecting arms below could; yields `None` for + // non-Decimal pairs and for operands Decimal rejects (e.g. float), + // which fall through to the usual dispatch / TypeError. + if let Some(result) = decimal::binary_op_value(self, other, decimal::BinOp::Mod, vm)? { + return Ok(Some(result)); + } match (self, other) { (Self::Int(a), Self::Int(b)) => { if *b == 0 { @@ -667,6 +767,13 @@ impl<'h> PyTrait<'h> for Value { } fn py_mult(&self, other: &Self, vm: &mut VM<'_, impl ResourceTracker>) -> RunResult> { + // Decimal probe first: captures any Decimal operand before the + // generic `Ref`-inspecting arms below could; yields `None` for + // non-Decimal pairs and for operands Decimal rejects (e.g. float), + // which fall through to the usual dispatch / TypeError. + if let Some(result) = decimal::binary_op_value(self, other, decimal::BinOp::Mul, vm)? { + return Ok(Some(result)); + } let interns = vm.interns; match (self, other) { // Numeric multiplication with overflow promotion to LongInt @@ -873,6 +980,13 @@ impl<'h> PyTrait<'h> for Value { } fn py_div(&self, other: &Self, vm: &mut VM<'_, impl ResourceTracker>) -> RunResult> { + // Decimal probe first: captures any Decimal operand before the + // generic `Ref`-inspecting arms below could; yields `None` for + // non-Decimal pairs and for operands Decimal rejects (e.g. float), + // which fall through to the usual dispatch / TypeError. + if let Some(result) = decimal::binary_op_value(self, other, decimal::BinOp::Div, vm)? { + return Ok(Some(result)); + } let interns = vm.interns; match (self, other) { // True division always returns float @@ -1031,6 +1145,13 @@ impl<'h> PyTrait<'h> for Value { } fn py_floordiv(&self, other: &Self, vm: &mut VM<'_, impl ResourceTracker>) -> RunResult> { + // Decimal probe first: captures any Decimal operand before the + // generic `Ref`-inspecting arms below could; yields `None` for + // non-Decimal pairs and for operands Decimal rejects (e.g. float), + // which fall through to the usual dispatch / TypeError. + if let Some(result) = decimal::binary_op_value(self, other, decimal::BinOp::FloorDiv, vm)? { + return Ok(Some(result)); + } match (self, other) { // Floor division: int // int returns int (Self::Int(a), Self::Int(b)) => { @@ -1160,6 +1281,13 @@ impl<'h> PyTrait<'h> for Value { } fn py_pow(&self, other: &Self, vm: &mut VM<'_, impl ResourceTracker>) -> RunResult> { + // Decimal probe first: captures any Decimal operand before the + // generic `Ref`-inspecting arms below could; yields `None` for + // non-Decimal pairs and for operands Decimal rejects (e.g. float), + // which fall through to the usual dispatch / TypeError. + if let Some(result) = decimal::binary_op_value(self, other, decimal::BinOp::Pow, vm)? { + return Ok(Some(result)); + } match (self, other) { (Self::Int(base), Self::Int(exp)) => { if *base == 0 && *exp < 0 { @@ -1580,14 +1708,18 @@ impl Value { self.id(vm) == other.id(vm) } - /// Python `==`, resolved to a definite boolean. + /// Python `==`, resolved to a definite boolean — the *identity-or-equality* + /// form (CPython's `PyObject_RichCompareBool`). /// /// Implements CPython's reflected comparison protocol on top of the /// one-sided [`PyTrait::py_eq_impl`]: tries `self == other`, and if that is /// `NotImplemented` (`None`) tries the reflected `other == self`. If neither /// operand's type recognises the other, the values are unequal. This is the - /// entry point the VM `==`/`!=`/`in` operators and all container element - /// comparisons use; per-type `py_eq_impl` impls never drive reflection themselves. + /// entry point the VM `in` operator and all container element comparisons + /// (dict/set key lookup, `list.index`, nested equality) use — those paths + /// keep the same-object shortcut inside `py_eq_impl`, exactly as CPython's + /// containers do. The bare `==`/`!=` operators use [`Self::py_eq_operator`] + /// instead; per-type `py_eq_impl` impls never drive reflection themselves. pub fn py_eq(&self, other: &Self, vm: &mut VM<'_, impl ResourceTracker>) -> RunResult { if let Some(result) = self.py_eq_impl(other, vm)? { Ok(result) @@ -1598,6 +1730,29 @@ impl Value { } } + /// 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 { + 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) + } + } + /// Reads the heap entry this value references, or `None` if it is not a /// heap reference. /// @@ -1643,7 +1798,12 @@ impl Value { } else { // Integral float outside i64 range hashes as the equivalent // big int, so an exactly-equal `float`/`int` pair (e.g. - // `2.0**100 == 2**100`) preserves `hash(a) == hash(b)`. + // `2.0**100 == 2**100`) preserves `hash(a) == hash(b)`. This + // also keeps `hash(float) == hash(Decimal(float))` for every + // float whose exact value `Decimal(f)` can represent (`Decimal` + // hashes integral values through the same `hash_python_long_int` + // path); a float so large that `Decimal(f)` rounds is no longer + // equal to that `Decimal`, so no hash agreement is owed. Ok(Some(hash_python_long_int( &BigInt::from_f64(*f).expect("finite f64 converts to BigInt"), ))) diff --git a/crates/monty/test_cases/class__type_errors.py b/crates/monty/test_cases/class__type_errors.py index a5565c678..3ce1862f6 100644 --- a/crates/monty/test_cases/class__type_errors.py +++ b/crates/monty/test_cases/class__type_errors.py @@ -224,3 +224,18 @@ def __str__(self): assert False, 'expected str to fail' except TypeError as exc: assert str(exc) == '__str__ returned non-string (type int)', '__str__ returning non-string' + + +# === Decimal constructor reports the class name === +from decimal import Decimal + + +class Widget: + pass + + +try: + Decimal(Widget()) + assert False, 'expected TypeError from Decimal(instance)' +except TypeError as exc: + assert str(exc) == 'conversion from Widget to Decimal is not supported', 'Decimal(instance) message' diff --git a/crates/monty/test_cases/decimal__arithmetic.py b/crates/monty/test_cases/decimal__arithmetic.py new file mode 100644 index 000000000..bb3d177ca --- /dev/null +++ b/crates/monty/test_cases/decimal__arithmetic.py @@ -0,0 +1,209 @@ +from decimal import Decimal as D +import decimal + +# === Exact / short results === +assert str(D('1.23') + D('4.56')) == '5.79', 'add' +assert str(D('1.1') + D('2.2')) == '3.3', 'add tenths' +assert str(D(10) - D(3)) == '7', 'sub' +assert str(D('1.5') * D('1.5')) == '2.25', 'mul' +assert str(D(10) / D(4)) == '2.5', 'div exact' +assert str(D(2) ** D(10)) == '1024', 'pow' +assert str(D(7) % D(3)) == '1', 'mod' +assert str(D(7) // D(3)) == '2', 'floordiv' +assert str(D(-7) % D(3)) == '-1', 'mod negative (sign of divisor)' +assert str(D(-7) // D(3)) == '-2', 'floordiv truncates toward zero (not floor)' + +# === prec-28 rounding of inexact results === +assert str(D(1) / D(3)) == '0.3333333333333333333333333333', '1/3 to 28 digits' +assert str(D(2) / D(3)) == '0.6666666666666666666666666667', '2/3 rounds half-even up' +assert str(D(100) / D(7)) == '14.28571428571428571428571429', '100/7 to 28 digits' + +# === Mixed Decimal / int / bool (both orders) === +assert str(D(5) + 2) == '7', 'decimal + int' +assert str(2 + D(5)) == '7', 'int + decimal' +assert str(10 - D(3)) == '7', 'int - decimal' +assert str(D(5) * 3) == '15', 'decimal * int' +assert str(D(10) / 2) == '5', 'decimal / int' +assert str(D(7) % 2) == '1', 'decimal % int' +assert str(D(7) // 2) == '3', 'decimal // int' +assert str(D(2) ** 3) == '8', 'decimal ** int' +assert str(D(5) + True) == '6', 'decimal + bool' +assert str(5**40 + D(0)) == '9094947017729282379150390625', 'bigint + decimal (fits D256)' + +# === Unary === +assert str(-D(5)) == '-5', 'unary minus' +assert str(+D(5)) == '5', 'unary plus' +assert str(-D('-1.5')) == '1.5', 'unary minus negative' +assert str(-D('2.5')) == '-2.5', 'unary minus on decimal' +# unary + / - round to the working precision +assert str(+D(D(0.1))) == '0.1000000000000000055511151231', 'unary plus rounds 55-digit float to 28' +assert str(-D(D(0.1))) == '-0.1000000000000000055511151231', 'unary minus rounds to 28' + +# === Infinity arithmetic (no raise) === +assert str(D('inf') + D('inf')) == 'Infinity', 'inf + inf' +assert str(D('inf') + D(1)) == 'Infinity', 'inf + finite' +assert str(D('inf') * D(2)) == 'Infinity', 'inf * 2' +assert str(D('-inf') * D(2)) == '-Infinity', '-inf * 2' +# 1/inf is zero (CPython keeps a huge negative exponent, `0E-1000026`; Monty +# clamps it — compare by value, which is zero in both). +assert D(1) / D('inf') == 0, '1 / inf is zero' + +# === NaN propagation (no raise) === +assert str(D('NaN') + D(1)) == 'NaN', 'NaN + 1' +assert str(D('NaN') * D(2)) == 'NaN', 'NaN * 2' +assert str(D(1) / D('NaN')) == 'NaN', '1 / NaN' +assert str(D('NaN') % D(2)) == 'NaN', 'NaN % 2' +assert str(-D('NaN')) == 'NaN', 'unary minus NaN' + +# === Power special values (no raise) === +assert str(D(-8) ** D(3)) == '-512', 'negative base, odd integer exponent' +assert str(D(-8) ** D(2)) == '64', 'negative base, even integer exponent' +assert str(D(2) ** D('3.0')) == '8', 'integer-valued decimal exponent is exact' +assert str(D(0) ** D(-1)) == 'Infinity', '0 ** -1 is Infinity (not an error)' +assert str(D(0) ** D(2)) == '0', '0 ** positive is 0' +assert str(D(2) ** D('inf')) == 'Infinity', '2 ** inf' +assert str(D(2) ** D('-inf')) == '0', '2 ** -inf' +assert str(D('inf') ** D(2)) == 'Infinity', 'inf ** 2' +assert str(D('inf') ** D(0)) == '1', 'inf ** 0 is 1' +assert str(D(2) ** D('NaN')) == 'NaN', '2 ** NaN propagates quietly (no fastnum panic)' +assert str(D('NaN') ** D(2)) == 'NaN', 'NaN ** 2' +assert str(D(2) ** D('0.5')) == '1.414213562373095048801688724', 'sqrt(2) via ** rounds to prec' +assert str(D('0.1') ** D('0.1')) == '0.7943282347242815020659182828', 'fractional base and exponent' +# A representable large power is computed; one beyond Monty's range raises Overflow +# (see decimal__errors.py and limitations/decimal.md for the smaller-range cutoff). +assert str(D(10) ** D('16400')) == '1.000000000000000000000000000E+16400', 'large in-range power' + +# === Floor division / modulo with infinity (no raise) === +assert str(D('inf') // D(2)) == 'Infinity', 'inf // finite is signed infinity' +assert str(D('-inf') // D(2)) == '-Infinity', '-inf // finite' +assert str(D('inf') // D(-2)) == '-Infinity', 'inf // negative finite flips sign' +assert str(D(2) // D('inf')) == '0', 'finite // inf is 0' +assert str(D(2) % D('inf')) == '2', 'finite % inf is the dividend' +assert str(D('inf') // D(0)) == 'Infinity', 'inf // 0 is Infinity (infinity precedes zero check)' + +# === Integer-division remainder keeps the ideal exponent === +assert str(D('7.0') % D(3)) == '1.0', 'remainder scale = min(operand exponents)' +assert str(D(7) % D('3.00')) == '1.00', 'remainder takes the divisor scale' +assert str(D(10) // D('0.003')) == '3333', 'floordiv across differing scales' +assert str(D('1e20') // D(3)) == '33333333333333333333', 'large in-precision quotient' +assert str(D('1e27') // D(1)) == '1000000000000000000000000000', '28-digit quotient still fits' + +# === divmod === +q, r = divmod(D(7), D(3)) +assert str(q) == '2' and str(r) == '1', 'divmod decimals' +q, r = divmod(D(7), 2) +assert str(q) == '3' and str(r) == '1', 'divmod decimal/int' +q, r = divmod(D(-7), D(3)) +assert str(q) == '-2' and str(r) == '-1', 'divmod negative' +q, r = divmod(D(10), D('0.003')) +assert str(q) == '3333' and str(r) == '0.001', 'divmod across differing scales' +q, r = divmod(D('NaN'), D(2)) +assert str(q) == 'NaN' and str(r) == 'NaN', 'divmod with NaN dividend' +q, r = divmod(D(2), D('inf')) +assert str(q) == '0' and str(r) == '2', 'divmod by infinity' +# divmod always agrees with the separate // and % operators +assert divmod(D(17), D(5)) == (D(17) // D(5), D(17) % D(5)), 'divmod == (//, %)' + +# === Float mixing raises TypeError (both orders, all ops) === +for fn, msg in [ + (lambda: D('1.5') + 1.5, "unsupported operand type(s) for +: 'decimal.Decimal' and 'float'"), + (lambda: 1.5 + D('1.5'), "unsupported operand type(s) for +: 'float' and 'decimal.Decimal'"), + (lambda: D(1) * 2.0, "unsupported operand type(s) for *: 'decimal.Decimal' and 'float'"), + (lambda: D(1) / 2.0, "unsupported operand type(s) for /: 'decimal.Decimal' and 'float'"), + (lambda: D(1) - 2.0, "unsupported operand type(s) for -: 'decimal.Decimal' and 'float'"), +]: + try: + fn() + assert False, 'expected TypeError' + except TypeError as exc: + assert str(exc) == msg, f'float-mix message: {exc}' + +# === Signs of zero results (CPython minus/plus/_divide parity) === +assert str(-D('0')) == '0', 'unary minus of +0 is +0' +assert str(-D('-0')) == '0', 'unary minus of -0 is +0' +assert str(+D('-0')) == '0', 'unary plus of -0 is +0' +assert not (-D('0')).is_signed(), 'negated zero is unsigned' +assert str(D(-1) // D(1000)) == '-0', 'zero floordiv quotient keeps the sign' +assert str(D(0) // D(-3)) == '-0', 'signed zero quotient' +assert str(D(-7) // D('inf')) == '-0', 'zero quotient by infinity keeps the sign' +q, r = divmod(D(-1), D(1000)) +assert str(q) == '-0' and str(r) == '-1', 'divmod zero-quotient sign' + +# === (-1) ** n parity for integral exponents in any representation === +assert str(D(-1) ** D('3.0')) == '-1', 'odd integral exponent with trailing zero' +assert str(D(-1) ** D('30E-1')) == '-1', 'odd integral exponent with fractional zeros' +assert str(D(-1) ** D('4.0')) == '1', 'even integral exponent' + +# === Huge int operands promote exactly === +assert str(D(1) + 10**100) == '1.000000000000000000000000000E+100', 'huge int operand' +assert D(10**50) == 10**50, '51-digit int equality' + +# === Emax / Etiny boundaries === +assert str(D('9.999999999999999999999999999E+999999') + D('0')) == '9.999999999999999999999999999E+999999', ( + 'Emax boundary value survives' +) +try: + D('9E+999999') * 10 + assert False, 'expected Overflow past Emax' +except decimal.Overflow as exc: + assert str(exc) == "[]", 'overflow message' +# A result below Etiny quietly underflows to a signed zero at Etiny. +assert str(D('1E-1000026') / D('1E+10')) == '0E-1000026', 'underflow to zero at Etiny' + +# === pow() builtin === +assert pow(D(2), 2) == 4, 'pow builtin decimal/int' +assert pow(2, D(2)) == 4, 'pow builtin int/decimal' +assert str(pow(D(2), D(3))) == '8', 'pow builtin decimal/decimal' +assert str(pow(D(2), D(3), D(5))) == '3', '3-arg pow all decimal' +assert str(pow(D(2), 3, 5)) == '3', '3-arg pow mixed int' +assert str(pow(D(-2), D(3), D(-5))) == '-3', '3-arg pow signs' +assert str(pow(True, D(3), 7)) == '1', '3-arg pow bool base promotes' +assert str(pow(D(2), 3, True)) == '0', '3-arg pow bool modulus promotes' +try: + pow(D(2), D('3.5'), D(5)) + assert False, 'expected InvalidOperation for non-integral 3-arg pow' +except decimal.InvalidOperation as exc: + assert str(exc) == "[]", '3-arg pow message' +# 2-arg pow with a non-promotable operand keeps the operator's message +try: + pow(D(2), 1.5) + assert False, 'expected TypeError from pow(Decimal, float)' +except TypeError as exc: + assert str(exc) == "unsupported operand type(s) for ** or pow(): 'decimal.Decimal' and 'float'", 'pow d/f message' +try: + pow(1.5, D(2)) + assert False, 'expected TypeError from pow(float, Decimal)' +except TypeError as exc: + assert str(exc) == "unsupported operand type(s) for ** or pow(): 'float' and 'decimal.Decimal'", 'pow f/d message' +# 3-arg pow with a Decimal and a non-promotable operand raises the +# three-operand message — unless a float is present, whose `__pow__` wins +# with the integers-only message. +for fn, expected in [ + (lambda: pow(2, D(3), 'x'), "unsupported operand type(s) for ** or pow(): 'int', 'decimal.Decimal', 'str'"), + (lambda: pow(True, D(3), 'x'), "unsupported operand type(s) for ** or pow(): 'bool', 'decimal.Decimal', 'str'"), + (lambda: pow(D(2), 'x', True), "unsupported operand type(s) for ** or pow(): 'decimal.Decimal', 'str', 'bool'"), + (lambda: pow('x', D(3), 5), "unsupported operand type(s) for ** or pow(): 'str', 'decimal.Decimal', 'int'"), + (lambda: pow(D(2), [1], 5), "unsupported operand type(s) for ** or pow(): 'decimal.Decimal', 'list', 'int'"), + (lambda: pow(D(2), 3, 1.5), 'pow() 3rd argument not allowed unless all arguments are integers'), + (lambda: pow(D(2), 1.5, 5), 'pow() 3rd argument not allowed unless all arguments are integers'), + (lambda: pow(1.5, D(2), 3), 'pow() 3rd argument not allowed unless all arguments are integers'), +]: + try: + fn() + assert False, 'expected TypeError from 3-arg pow' + except TypeError as exc: + assert str(exc) == expected, f'3-arg pow TypeError message: {exc}' + +# === Sequence repetition by a Decimal raises CPython's non-int message === +for fn in [lambda: 'a' * D(2), lambda: D(2) * 'a', lambda: [1] * D(2), lambda: D(2) * (1, 2), lambda: b'x' * D(2)]: + try: + fn() + assert False, 'expected TypeError from sequence * Decimal' + except TypeError as exc: + assert str(exc) == "can't multiply sequence by non-int of type 'decimal.Decimal'", f'seq-repeat message: {exc}' +# `range` has no repeat, so it keeps the generic message +try: + D(2) * range(3) + assert False, 'expected TypeError from range * Decimal' +except TypeError as exc: + assert str(exc) == "unsupported operand type(s) for *: 'decimal.Decimal' and 'range'", 'range keeps generic message' diff --git a/crates/monty/test_cases/decimal__basics.py b/crates/monty/test_cases/decimal__basics.py new file mode 100644 index 000000000..e137571b6 --- /dev/null +++ b/crates/monty/test_cases/decimal__basics.py @@ -0,0 +1,138 @@ +from decimal import Decimal +import decimal + +# === Construction from str: str() preserves coefficient and exponent === +assert str(Decimal('0')) == '0', 'zero' +assert str(Decimal('1.23')) == '1.23', 'simple decimal' +assert str(Decimal('1.20')) == '1.20', 'trailing zero preserved' +assert str(Decimal('-1.5')) == '-1.5', 'negative' +assert str(Decimal('1E+5')) == '1E+5', 'positive exponent kept' +assert str(Decimal('123e1')) == '1.23E+3', 'exponent re-normalised like CPython' +assert str(Decimal('1e-7')) == '1E-7', 'scientific for small magnitudes' +assert str(Decimal('0.000001')) == '0.000001', 'plain form near zero' +assert str(Decimal('0E-10')) == '0E-10', 'zero with exponent' +assert str(Decimal('-0')) == '-0', 'negative zero' + +# === Construction from str: special values === +assert str(Decimal('inf')) == 'Infinity', 'lowercase inf' +assert str(Decimal('Infinity')) == 'Infinity', 'Infinity' +assert str(Decimal('-inf')) == '-Infinity', 'negative infinity' +assert str(Decimal('nan')) == 'NaN', 'lowercase nan' +assert str(Decimal('NaN')) == 'NaN', 'NaN' + +# === Construction from str: whitespace and underscores (CPython-accepted) === +assert str(Decimal(' 1.5 ')) == '1.5', 'surrounding whitespace stripped' +assert str(Decimal('1_000')) == '1000', 'underscore separators' +assert str(Decimal('1_0.0_1')) == '10.01', 'underscores around the point' + +# === Construction from int / bool / Decimal === +assert str(Decimal(123)) == '123', 'from int' +assert str(Decimal(-45)) == '-45', 'from negative int' +assert str(Decimal(0)) == '0', 'from int zero' +assert str(Decimal(True)) == '1', 'from bool True' +assert str(Decimal(False)) == '0', 'from bool False' +assert str(Decimal()) == '0', 'no argument is zero' +assert str(Decimal(Decimal('7.50'))) == '7.50', 'copy preserves scale' +assert str(Decimal(10**18)) == '1000000000000000000', 'large int that fits' + +# === Construction at the representable exponent boundary (matches CPython) === +# Monty's fixed-width D256 caps the scale at ±16384; values exactly at the cap +# still round-trip and stay non-zero. Magnitudes past the cap raise +# decimal.Overflow (monty-specific, so tested in tests/decimal_range.rs). +assert str(Decimal('1E-16384')) == '1E-16384', 'smallest representable kept' +assert bool(Decimal('1E-16384')) is True, 'boundary value is non-zero' +assert str(Decimal('1E+16384')) == '1E+16384', 'largest representable kept' + +# === Construction from float is the exact binary expansion === +assert str(Decimal(0.5)) == '0.5', 'exact float 0.5' +assert str(Decimal(0.1)) == '0.1000000000000000055511151231257827021181583404541015625', 'exact float 0.1' +assert str(Decimal(2.0)) == '2', 'integral float' + +# === repr() wraps str() in Decimal('...') === +assert repr(Decimal('1.23')) == "Decimal('1.23')", 'repr basic' +assert repr(Decimal('1.20')) == "Decimal('1.20')", 'repr trailing zero' +assert repr(Decimal('1E+5')) == "Decimal('1E+5')", 'repr exponent' +assert repr(Decimal('-Infinity')) == "Decimal('-Infinity')", 'repr -inf' +assert repr(Decimal('NaN')) == "Decimal('NaN')", 'repr nan' + +# === bool(): only zero is falsy === +assert bool(Decimal('1.5')) is True, 'nonzero is truthy' +assert bool(Decimal('-2')) is True, 'negative is truthy' +assert bool(Decimal('0')) is False, 'zero is falsy' +assert bool(Decimal('0.0')) is False, 'scaled zero is falsy' +assert bool(Decimal('NaN')) is True, 'NaN is truthy' +assert bool(Decimal('Infinity')) is True, 'infinity is truthy' + +# === isinstance === +assert isinstance(Decimal('1'), Decimal), 'isinstance true' +assert not isinstance(1, Decimal), 'int is not Decimal' +assert not isinstance(1.0, Decimal), 'float is not Decimal' + +# === Rounding-mode constants are plain strings equal to their names === +assert decimal.ROUND_HALF_EVEN == 'ROUND_HALF_EVEN', 'half even' +assert decimal.ROUND_CEILING == 'ROUND_CEILING', 'ceiling' +assert decimal.ROUND_FLOOR == 'ROUND_FLOOR', 'floor' +assert decimal.ROUND_UP == 'ROUND_UP', 'up' +assert decimal.ROUND_DOWN == 'ROUND_DOWN', 'down' +assert decimal.ROUND_HALF_UP == 'ROUND_HALF_UP', 'half up' +assert decimal.ROUND_HALF_DOWN == 'ROUND_HALF_DOWN', 'half down' +assert decimal.ROUND_05UP == 'ROUND_05UP', '05up' + +# === Construction errors === +# Unparsable string -> InvalidOperation([ConversionSyntax]) +try: + Decimal('abc') + assert False, 'expected InvalidOperation' +except decimal.InvalidOperation as exc: + assert str(exc) == "[]", 'conversion syntax message' + +# Empty string -> InvalidOperation([ConversionSyntax]) +try: + Decimal('') + assert False, 'expected InvalidOperation' +except decimal.InvalidOperation as exc: + assert str(exc) == "[]", 'empty string message' + +# InvalidOperation participates in the ArithmeticError / DecimalException tree +try: + Decimal('not a number') + assert False, 'expected ArithmeticError' +except ArithmeticError: + pass + +try: + Decimal('not a number') + assert False, 'expected DecimalException' +except decimal.DecimalException: + pass + +# None -> TypeError +try: + Decimal(None) + assert False, 'expected TypeError' +except TypeError as exc: + assert str(exc) == 'conversion from NoneType to Decimal is not supported', 'none message' + +# === Signaling NaN and NaN payloads === +assert str(Decimal('snan')) == 'sNaN', 'sNaN spelling' +assert repr(Decimal('-snan123')) == "Decimal('-sNaN123')", 'signed sNaN payload repr' +assert str(Decimal('-nan456')) == '-NaN456', 'signed quiet NaN payload' +assert str(Decimal('nan0')) == 'NaN', 'zero payload prints bare NaN' +assert Decimal('sNaN').is_snan(), 'is_snan' +assert not Decimal('sNaN').is_qnan(), 'sNaN is not qNaN' +assert Decimal('sNaN').is_nan(), 'sNaN is a NaN' +assert Decimal('nan123').is_qnan(), 'payload NaN is quiet' +assert (Decimal('NaN123') + 1).is_qnan(), 'quiet NaN propagates through arithmetic' +assert str(Decimal('NaN123') + 1) == 'NaN123', 'payload survives propagation' +tup = Decimal('sNaN45').as_tuple() +assert tup.sign == 0 and tup.digits == (4, 5) and tup.exponent == 'N', 'sNaN as_tuple' +assert Decimal('NaN').as_tuple().digits == (), 'empty payload digits' +assert str(Decimal((1, (4, 5), 'N'))) == '-sNaN45', 'sNaN from tuple form' + +# === Huge exponent literals (C-module bounds) === +assert str(Decimal('1E+425000000')) == '1E+425000000', 'huge exponent literal' +assert str(Decimal('1E-32768')) == '1E-32768', 'tiny magnitude literal' +assert str(Decimal('1E+999999999999999998')) == '1E+999999999999999998', 'near-max exponent' + +# === value= keyword === +assert str(Decimal(value='3')) == '3', 'value keyword argument' diff --git a/crates/monty/test_cases/decimal__cmp_hash.py b/crates/monty/test_cases/decimal__cmp_hash.py new file mode 100644 index 000000000..0d807dab7 --- /dev/null +++ b/crates/monty/test_cases/decimal__cmp_hash.py @@ -0,0 +1,185 @@ +from decimal import Decimal +import decimal + +# === Decimal vs Decimal equality (numeric, scale-insensitive) === +assert Decimal('1.5') == Decimal('1.5'), 'identical' +assert Decimal('1.0') == Decimal('1.00'), 'scale-insensitive equality' +assert Decimal('1.0') == Decimal('1'), 'trailing zero vs none' +assert Decimal('0') == Decimal('-0'), 'zero vs negative zero' +assert Decimal('1.5') != Decimal('1.6'), 'distinct values' + +# === Decimal vs int / bool === +assert Decimal(5) == 5, 'decimal == int' +assert 5 == Decimal(5), 'int == decimal' +assert Decimal('5.0') == 5, 'scaled decimal == int' +assert Decimal(1) == True, 'decimal == True' +assert Decimal(0) == False, 'decimal == False' +assert Decimal(5) != True, 'decimal != True' +assert Decimal('1.5') != 1, 'non-integral != int' + +# === Decimal vs LongInt (big int beyond i64) === +assert Decimal(5**40) == 5**40, 'decimal == big int' +assert 5**40 == Decimal(5**40), 'big int == decimal' +assert Decimal(10**30) == 10**30, 'power of ten big int' +assert Decimal('1E+40') == 10**40, 'scientific decimal == big int' +assert Decimal(5**40) != 5**40 + 1, 'big int inequality' + +# === Decimal vs float (exact semantics) === +assert Decimal('0.5') == 0.5, 'exact-in-binary float equal' +assert Decimal('0.1') != 0.1, 'non-exact float not equal' +assert Decimal(0.1) == 0.1, 'Decimal(float) round-trips to the float' +assert Decimal('2') == 2.0, 'integral decimal == float' + +# === Ordering === +assert Decimal('1.5') < Decimal('2'), 'decimal < decimal' +assert Decimal('1.5') < 2, 'decimal < int' +assert Decimal('1.5') < 2.0, 'decimal < float' +assert Decimal('2.5') > 2, 'decimal > int' +assert Decimal('1.5') <= Decimal('1.50'), 'decimal <= equal-scaled' +assert Decimal('1.5') >= Decimal('1.50'), 'decimal >= equal-scaled' +assert 2 > Decimal('1.5'), 'int > decimal' +assert Decimal(5**40) < 5**40 + 1, 'big int ordering' +assert Decimal('-1.5') < 0, 'negative ordering' +assert sorted([Decimal('3'), Decimal('1'), Decimal('2')]) == [Decimal('1'), Decimal('2'), Decimal('3')], 'sortable' + +# === Huge-exponent vs small int (magnitude short-circuit, both signs) === +assert Decimal('1E+6145') > 5, 'huge positive > small int' +assert not (Decimal('1E+6145') == 5), 'huge positive != small int' +assert Decimal('1E+6145') > -5, 'huge positive > negative int' +assert Decimal('-1E+6145') < 5, 'huge negative < positive int' +assert Decimal('-1E+6145') < -5, 'huge negative < small negative int' +assert Decimal('-1E+6145') < 0, 'huge negative < zero' +assert 5 < Decimal('1E+6145'), 'small int < huge positive (reversed)' +# |d| < 1 has zero integer digits, so it is smaller than any nonzero int +assert Decimal('0.5') < 1, 'fraction < 1' +assert Decimal('0.999') < 1, 'near-one fraction < 1' +assert Decimal('-0.5') > -1, 'negative fraction > -1' +assert Decimal('0.5') > 0, 'positive fraction > 0' +assert Decimal('0.5') > -1, 'positive fraction > negative int' + +# === Infinity ordering === +assert Decimal('inf') == Decimal('Infinity'), 'inf equality' +assert Decimal('-inf') < Decimal('inf'), '-inf < inf' +assert Decimal('inf') > 10**1000, 'inf > any int' +assert Decimal('-inf') < Decimal('-1000000'), '-inf < finite' +assert not (Decimal('inf') < Decimal('inf')), 'inf not < inf' + +# === NaN: == / != never raise; ordering raises InvalidOperation === +assert Decimal('NaN') != Decimal('NaN'), 'NaN != NaN' +assert not (Decimal('NaN') == Decimal('NaN')), 'NaN == NaN is False' +assert Decimal('NaN') != 1, 'NaN != int' +assert not (Decimal('NaN') == 1), 'NaN == int is False' + +for op in ['lt', 'le', 'gt', 'ge']: + try: + if op == 'lt': + _ = Decimal('NaN') < 1 + elif op == 'le': + _ = Decimal('NaN') <= 1 + elif op == 'gt': + _ = Decimal('NaN') > 1 + else: + _ = Decimal('NaN') >= 1 + assert False, f'expected InvalidOperation for {op}' + except decimal.InvalidOperation as exc: + assert str(exc) == "[]", f'{op} message' + +# reversed operand and float NaN also raise +try: + _ = 1 < Decimal('NaN') + assert False, 'expected InvalidOperation' +except decimal.InvalidOperation: + pass +try: + _ = Decimal('1') < float('nan') + assert False, 'expected InvalidOperation' +except decimal.InvalidOperation: + pass + +# === Hashing: equal numbers hash equally across types === +assert hash(Decimal(5)) == hash(5), 'hash decimal int == hash int' +assert hash(Decimal('5.0')) == hash(5), 'scaled integral hash == int' +assert hash(Decimal(0)) == hash(0), 'hash zero' +assert hash(Decimal(-7)) == hash(-7), 'hash negative int' +assert hash(Decimal('1.5')) == hash(1.5), 'hash decimal == hash float' +assert hash(Decimal('0.5')) == hash(0.5), 'hash exact float' +assert hash(Decimal(5**40)) == hash(5**40), 'hash big integral == hash big int' +assert hash(Decimal('1.0')) == hash(Decimal('1.00')), 'equal decimals hash equal' +assert hash(Decimal('1.0')) == hash(Decimal('1')), 'integral scales hash equal' +# NaN hashes without raising (value is unspecified) +_ = hash(Decimal('NaN')) +_ = hash(Decimal('inf')) + +# === Large integer-valued floats: float, int and Decimal hash equally === +# (integer-valued floats above 2**63 fit D256's precision, so all three agree) +assert hash(Decimal(1e19)) == hash(1e19), 'Decimal(float) == hash float, > i64' +assert hash(Decimal('1e19')) == hash(1e19), 'Decimal(str) integral == hash float' +assert hash(1e19) == hash(10**19), 'integer-valued float == big int hash' +assert hash(Decimal(1e19)) == hash(10**19), 'Decimal == big int hash' +assert hash(2.0**63) == hash(Decimal(2**63)), 'i64 boundary float == Decimal hash' +assert hash(1e25) == hash(Decimal(1e25)), 'larger integral float == Decimal(float) hash' +# float and the equal Decimal always share a dict/set slot +assert 1e19 in {Decimal('1e19')}, 'float in decimal set' +assert Decimal('1e19') in {1e19}, 'decimal in float set' +assert {1e19: 'a'}[Decimal('1e19')] == 'a', 'float-keyed dict found by decimal' +assert len({Decimal('1e19'), 1e19, 10**19}) == 1, 'float, decimal, int collapse to one' + +# === Mixed-key dict / set membership === +d = {Decimal('1'): 'a'} +assert d[1] == 'a', 'int key finds decimal key' +d[1] = 'b' +assert d[Decimal('1')] == 'b', 'decimal key finds int-updated entry' +assert len(d) == 1, 'Decimal(1) and 1 are the same key' + +s = {Decimal('2'), 2, Decimal('2.0')} +assert len(s) == 1, 'equal numbers collapse to one set element' +assert Decimal('2') in s, 'membership' +assert 2 in s, 'int membership in decimal set' + +# === Signaling NaN: equality raises, hashing raises === +try: + Decimal('sNaN') == 1 + assert False, 'expected InvalidOperation from sNaN equality' +except decimal.InvalidOperation as exc: + assert str(exc) == "[]", 'sNaN eq message' +try: + hash(Decimal('sNaN')) + assert False, 'expected TypeError from sNaN hash' +except TypeError as exc: + assert str(exc) == 'Cannot hash a signaling NaN value', 'sNaN hash message' + +# === Ordering against a non-number raises CPython's TypeError === +try: + Decimal('1') < 'x' + assert False, 'expected TypeError from ordering against str' +except TypeError as exc: + assert str(exc) == "'<' not supported between instances of 'decimal.Decimal' and 'str'", 'ordering message' +try: + [] >= Decimal('1') + assert False, 'expected TypeError from reversed ordering' +except TypeError as exc: + assert str(exc) == "'>=' not supported between instances of 'list' and 'decimal.Decimal'", ( + 'reversed ordering message' + ) + +# === NaN equality is non-reflexive on the == operator, identity-based in containers === +x = Decimal('NaN') +assert not (x == x), 'NaN == same NaN object is False' +assert x != x, 'NaN != same NaN object is True' +d = {x: 1} +assert d[x] == 1, 'dict lookup uses identity for the same NaN key object' +assert x in [x], 'list membership uses identity' +assert [x].index(x) == 0, 'list.index uses identity' +assert x in {x}, 'set membership uses identity' +assert [x] == [x], 'container equality compares NaN elements by identity' +assert (x,) == (x,), 'tuple equality compares NaN elements by identity' +s = Decimal('sNaN') +try: + s == s + assert False, 'expected InvalidOperation from sNaN == sNaN' +except decimal.InvalidOperation as exc: + assert str(exc) == "[]", 'sNaN self-eq message' +items = [s] +assert items == items and s in items and items.index(s) == 0, 'sNaN containers use identity, no raise' +f = Decimal('1.5') +assert f == f and not (f != f), 'ordinary Decimal self-equality' diff --git a/crates/monty/test_cases/decimal__errors.py b/crates/monty/test_cases/decimal__errors.py new file mode 100644 index 000000000..17f15e1de --- /dev/null +++ b/crates/monty/test_cases/decimal__errors.py @@ -0,0 +1,168 @@ +from decimal import Decimal as D +import decimal + + +def expect(fn, exc_type, message): + try: + fn() + assert False, f'expected {exc_type.__name__}' + except exc_type as exc: + assert str(exc) == message, f'{message!r} != {str(exc)!r}' + + +# === Division by zero === +# x / 0 (x != 0) -> DivisionByZero +expect(lambda: D(1) / D(0), decimal.DivisionByZero, "[]") +expect(lambda: D(1) // D(0), decimal.DivisionByZero, "[]") +expect(lambda: D(1) / 0, decimal.DivisionByZero, "[]") +expect(lambda: D('2.5') / D(0), decimal.DivisionByZero, "[]") + +# 0 / 0 -> InvalidOperation (DivisionUndefined) +expect(lambda: D(0) / D(0), decimal.InvalidOperation, "[]") +expect(lambda: D(0) // D(0), decimal.InvalidOperation, "[]") +expect(lambda: D(0) % D(0), decimal.InvalidOperation, "[]") + +# x % 0 (x != 0) -> InvalidOperation (plain) +expect(lambda: D(1) % D(0), decimal.InvalidOperation, "[]") + +# divmod(x, 0) reports both InvalidOperation and DivisionByZero +expect( + lambda: divmod(D(1), D(0)), + decimal.InvalidOperation, + "[, ]", +) +expect(lambda: divmod(D(0), D(0)), decimal.InvalidOperation, "[]") + +# === Invalid infinity operations === +expect(lambda: D('inf') - D('inf'), decimal.InvalidOperation, "[]") +expect(lambda: D('-inf') + D('inf'), decimal.InvalidOperation, "[]") +expect(lambda: D('inf') * D(0), decimal.InvalidOperation, "[]") +expect(lambda: D('inf') / D('inf'), decimal.InvalidOperation, "[]") +expect(lambda: D('inf') % D(2), decimal.InvalidOperation, "[]") +expect(lambda: D('inf') // D('inf'), decimal.InvalidOperation, "[]") +expect(lambda: divmod(D('inf'), D(2)), decimal.InvalidOperation, "[]") + +# === Integer division whose quotient exceeds precision -> DivisionImpossible === +# CPython refuses to materialise an integer quotient wider than `context.prec` +# (default 28 digits), so `//`, `%` and `divmod` all raise rather than rounding. +expect(lambda: D('1e40') // D(3), decimal.InvalidOperation, "[]") +expect(lambda: D('1e28') // D(1), decimal.InvalidOperation, "[]") +expect(lambda: D('1e40') % D(7), decimal.InvalidOperation, "[]") +expect(lambda: divmod(D('1e40'), D(3)), decimal.InvalidOperation, "[]") +# DivisionImpossible is catchable as InvalidOperation's parents. +expect(lambda: D('1e40') // D(3), decimal.DecimalException, "[]") +expect(lambda: D('1e40') // D(3), ArithmeticError, "[]") + +# === Power errors === +# Negative base to a non-integer power is undefined over the reals. +expect(lambda: D(-2) ** D('0.5'), decimal.InvalidOperation, "[]") +expect(lambda: D(-4) ** D('0.5'), decimal.InvalidOperation, "[]") +# A result whose magnitude exceeds the representable range overflows (rather than +# saturating to Infinity or — for an astronomically large exponent — crashing). +expect(lambda: D(2) ** D('1E1000'), decimal.Overflow, "[]") +expect(lambda: D(2) ** D('1E16384'), decimal.Overflow, "[]") + +# === Hierarchy: DivisionByZero is catchable as ZeroDivisionError === +try: + D(1) / D(0) + assert False, 'expected ZeroDivisionError' +except ZeroDivisionError: + pass + +# DivisionByZero and InvalidOperation are catchable as DecimalException and ArithmeticError +for fn in [lambda: D(1) / D(0), lambda: D(0) / D(0), lambda: D('inf') - D('inf')]: + try: + fn() + assert False, 'expected DecimalException' + except decimal.DecimalException: + pass +for fn in [lambda: D(1) / D(0), lambda: D(0) / D(0)]: + try: + fn() + assert False, 'expected ArithmeticError' + except ArithmeticError: + pass + +# === Construction error is InvalidOperation (ConversionSyntax) === +expect(lambda: D('not a number'), decimal.InvalidOperation, "[]") + + +# === Full signal exception taxonomy (multi-parent MRO) === +# These signals are not raised by default (untrapped), but the classes are +# importable and the subclass relationships must match CPython. We exercise the +# `is_subclass_of` wiring by raising each and catching via every parent. +def caught_as(raise_type, catch_type): + # Construct the signal *outside* the try so a constructor regression + # surfaces as a test failure rather than being swallowed by the + # `except BaseException` fallback (which would make the negative + # assertions below pass vacuously). + exc = raise_type('signal') + try: + raise exc + except catch_type: + return True + except BaseException: + return False + + +# Underflow ⊂ (Inexact, Rounded, Subnormal, DecimalException, ArithmeticError) +assert caught_as(decimal.Underflow, decimal.Inexact), 'Underflow is Inexact' +assert caught_as(decimal.Underflow, decimal.Rounded), 'Underflow is Rounded' +assert caught_as(decimal.Underflow, decimal.Subnormal), 'Underflow is Subnormal' +assert caught_as(decimal.Underflow, decimal.DecimalException), 'Underflow is DecimalException' +assert caught_as(decimal.Underflow, ArithmeticError), 'Underflow is ArithmeticError' +# Overflow ⊂ (Inexact, Rounded) +assert caught_as(decimal.Overflow, decimal.Inexact), 'Overflow is Inexact' +assert caught_as(decimal.Overflow, decimal.Rounded), 'Overflow is Rounded' +# Inexact / Rounded / Subnormal / Clamped ⊂ DecimalException ⊂ ArithmeticError +for leaf in [decimal.Inexact, decimal.Rounded, decimal.Subnormal, decimal.Clamped]: + assert caught_as(leaf, decimal.DecimalException), f'{leaf.__name__} is DecimalException' + assert caught_as(leaf, ArithmeticError), f'{leaf.__name__} is ArithmeticError' +# FloatOperation ⊂ (DecimalException, TypeError) +assert caught_as(decimal.FloatOperation, decimal.DecimalException), 'FloatOperation is DecimalException' +assert caught_as(decimal.FloatOperation, TypeError), 'FloatOperation is TypeError' +# Negative: a parent is not a subclass of its child +assert not caught_as(decimal.Inexact, decimal.Overflow), 'Inexact is not Overflow' +assert not caught_as(decimal.DecimalException, decimal.Inexact), 'DecimalException is not Inexact' + +# === Finer InvalidOperation condition subtypes === +# Importable and catchable as `InvalidOperation` (and `DivisionUndefined` as a +# `ZeroDivisionError`), matching CPython. Monty raises plain `InvalidOperation`, +# never these subtypes, so they participate in the hierarchy but are not raised. +for subtype in [ + decimal.ConversionSyntax, + decimal.DivisionImpossible, + decimal.DivisionUndefined, + decimal.InvalidContext, +]: + assert caught_as(subtype, decimal.InvalidOperation), f'{subtype.__name__} is InvalidOperation' + assert caught_as(subtype, decimal.DecimalException), f'{subtype.__name__} is DecimalException' + assert caught_as(subtype, ArithmeticError), f'{subtype.__name__} is ArithmeticError' +assert caught_as(decimal.DivisionUndefined, ZeroDivisionError), 'DivisionUndefined is ZeroDivisionError' +assert not caught_as(decimal.ConversionSyntax, decimal.DivisionImpossible), 'siblings are unrelated' +assert not caught_as(decimal.InvalidOperation, decimal.ConversionSyntax), 'parent is not its subtype' + +# === Exponent literal bounds (C-module parity) === +expect(lambda: D('1E+1000000000000000000'), decimal.InvalidOperation, "[]") +expect(lambda: D('1E-2000000000000000000'), decimal.InvalidOperation, "[]") + +# === Signaling NaN operands raise InvalidOperation === +expect(lambda: D('sNaN') + 1, decimal.InvalidOperation, "[]") +expect(lambda: D(1) * D('-sNaN5'), decimal.InvalidOperation, "[]") +expect(lambda: D('sNaN') < 1, decimal.InvalidOperation, "[]") + +# === quantize bounds === +expect(lambda: D(1).quantize(D('inf')), decimal.InvalidOperation, "[]") +expect(lambda: D('1e100').quantize(D('1e-100')), decimal.InvalidOperation, "[]") + +# === Tuple-form exponent bounds (same C-module limits as string literals) === +expect(lambda: D((0, (5,), 9223372036854775807)), decimal.InvalidOperation, "[]") +expect(lambda: D((0, (5,), -2 * 10**18)), decimal.InvalidOperation, "[]") +assert str(D((0, (5,), 999999999999999999))) == '5E+999999999999999999', 'boundary exponent accepted' +expect(lambda: D((0, (5,), 10**30)), OverflowError, 'Python int too large to convert to C ssize_t') + +# === round(Decimal, huge int) overflows like CPython's ssize_t conversion === +expect(lambda: round(D('1.5'), 10**30), OverflowError, 'Python int too large to convert to C ssize_t') + +# === copy_sign missing-argument wording (C-module parser) === +expect(lambda: D(1).copy_sign(), TypeError, "function missing required argument 'other' (pos 1)") diff --git a/crates/monty/test_cases/decimal__format.py b/crates/monty/test_cases/decimal__format.py new file mode 100644 index 000000000..fd7db9137 --- /dev/null +++ b/crates/monty/test_cases/decimal__format.py @@ -0,0 +1,139 @@ +from decimal import Decimal as D + +# === Empty spec falls back to str() (may be scientific) === +assert f'{D("1.20")}' == '1.20', 'empty spec keeps trailing zero' +assert f'{D("1E+5")}' == '1E+5', 'empty spec keeps exponent' +assert f'{D("1.20E+5")}' == '1.20E+5', 'empty spec scaled exponent' + +# === Fixed (f) — native digits, never via f64 === +assert f'{D("1.5"):.2f}' == '1.50', 'fixed pads fraction' +assert f'{D("1.10"):.2f}' == '1.10', 'fixed keeps value' +assert f'{D("1.10"):.20f}' == '1.10000000000000000000', 'fixed 20 places stays exact (not f64)' +assert f'{D("2.5"):.0f}' == '2', 'fixed 0 places half-even' +assert f'{D("2.675"):.2f}' == '2.68', 'fixed rounds half-even' +assert f'{D("1.5"):f}' == '1.5', 'fixed default precision uses value' + +# === Fill / align / width / zero-pad === +assert f'{D("1.5"):10.2f}' == ' 1.50', 'width right-align default' +assert f'{D("1.5"):<10.2f}' == '1.50 ', 'left align' +assert f'{D("1.5"):>10.2f}' == ' 1.50', 'right align' +assert f'{D("1.5"):^10.2f}' == ' 1.50 ', 'center align' +assert f'{D("1.5"):010.2f}' == '0000001.50', 'zero pad' +assert f'{D("1.5"):*^10}' == '***1.5****', 'custom fill center' + +# === Sign === +assert f'{D("1.5"):+.2f}' == '+1.50', 'plus sign' +assert f'{D("1.5"): .2f}' == ' 1.50', 'space sign' +assert f'{D("-1.5"):+.2f}' == '-1.50', 'negative keeps minus' + +# === Grouping === +assert f'{D("1234567")}:{D("1234567"):,}' == '1234567:1,234,567', 'comma grouping' +assert f'{D("1234567.891"):,.2f}' == '1,234,567.89', 'money format' +assert f'{D("1234567"):_}' == '1_234_567', 'underscore grouping' + +# === Scientific (e / E) — no two-digit exponent padding === +assert f'{D("1234.5"):.3e}' == '1.234e+3', 'scientific lowercase' +assert f'{D("1234.5"):.3E}' == '1.234E+3', 'scientific uppercase' +assert f'{D("1.5"):e}' == '1.5e+0', 'scientific default' + +# === General (g) === +assert f'{D("1234.5"):.3g}' == '1.23e+3', 'general picks scientific' +assert f'{D("0.0001234"):.3g}' == '0.000123', 'general picks fixed' +assert f'{D("1.5"):g}' == '1.5', 'general default' + +# === Percent === +assert f'{D("0.1234"):.2%}' == '12.34%', 'percent' +assert f'{D("0.5"):%}' == '50%', 'percent default' + +# === Precision without a type behaves like g === +assert f'{D("1.23456"):.4}' == '1.235', 'precision-only short' +assert f'{D("123.456"):.2}' == '1.2E+2', 'precision-only scientific' + +# === Specials === +assert f'{D("inf"):f}' == 'Infinity', 'infinity' +assert f'{D("nan")}' == 'NaN', 'nan' +assert f'{D("-2.5"):.2f}' == '-2.50', 'negative fixed' + +# === General (g) keeps significant trailing zeros (unlike float g) === +assert f'{D("1.20"):g}' == '1.20', 'g keeps trailing zero' +assert f'{D("120.0"):g}' == '120.0', 'g keeps trailing zero with int part' +assert f'{D("1.0"):.3g}' == '1.0', 'g precision does not pad short value' +assert f'{D("1.2300"):.6g}' == '1.2300', 'g keeps zeros within precision' +assert f'{D("1000"):.3g}' == '1.00e+3', 'g scientific keeps trailing zeros' +assert f'{D("1200"):.3g}' == '1.20e+3', 'g scientific keeps one trailing zero' +assert f'{D("100"):.2g}' == '1.0e+2', 'g scientific pads to precision' +assert f'{D("1.23456789"):g}' == '1.23456789', 'g with no precision keeps all digits' + +# === General (g) zero keeps its exponent === +assert f'{D("0.00"):.3g}' == '0.00', 'g zero keeps scale' +assert f'{D("0E-5"):g}' == '0.00000', 'g zero keeps negative exponent' +assert f'{D("0E+1"):g}' == '0e+1', 'g zero with positive exponent stays scientific' + +# === General (g) fixed/scientific threshold matches decimal (not float) === +assert f'{D("1.23e-5"):g}' == '0.0000123', 'g fixed down to leftdigits > -6' +assert f'{D("1.23e-6"):g}' == '0.00000123', 'g still fixed at the -6 boundary' + +# === Precision without a type uses uppercase G, keeps zeros === +assert f'{D("1.20"):.3}' == '1.20', 'precision-only keeps trailing zero' +assert f'{D("0.00"):.3}' == '0.00', 'precision-only zero keeps scale' +assert f'{D("0E+1"):.3}' == '0E+1', 'precision-only zero positive exponent' +assert f'{D("1000"):.3}' == '1.00E+3', 'precision-only uppercase scientific' + +# === Scientific (e) zero shifts the exponent by the precision === +assert f'{D("0"):.2e}' == '0.00e+2', 'e zero exponent shifted by precision' +assert f'{D("0"):.3e}' == '0.000e+3', 'e zero exponent shifted by precision (3)' +assert f'{D("0E+5"):.2e}' == '0.00e+7', 'e zero adds value exponent and precision' +assert f'{D("0"):e}' == '0e+0', 'e zero no precision' +assert f'{D("0.00"):e}' == '0e-2', 'e zero no precision keeps exponent' + +# === Rounding carry recomputes the digit count (no stray trailing digit) === +assert f'{D("9.99"):.1e}' == '1.0e+1', 'e carry collapses to one fractional digit' +assert f'{D("9.6"):.0e}' == '1e+1', 'e carry with zero precision has no fraction' +assert f'{D("9.99"):.2}' == '10', 'precision-only carry drops trailing zero' +assert f'{D("99.5"):.2g}' == '1.0e+2', 'g half-even carry to power of ten' + +# === Percent: suffix on non-finite, zero with positive exponent === +assert f'{D("inf"):%}' == 'Infinity%', 'percent on infinity keeps suffix' +assert f'{D("-inf"):.2%}' == '-Infinity%', 'percent on -infinity keeps sign and suffix' +assert f'{D("nan"):.2%}' == 'NaN%', 'percent on nan keeps suffix' +assert f'{D("0"):%}' == '0%', 'percent of zero is 0%, not 000%' +assert f'{D("-0"):%}' == '-0%', 'percent of negative zero' + +# === Fixed: zero with positive exponent renders as a single 0 === +assert f'{D("0E2"):f}' == '0', 'fixed zero with positive exponent' +assert f'{D("0E+3"):f}' == '0', 'fixed zero with positive exponent (3)' +assert f'{D("0.00"):f}' == '0.00', 'fixed zero with scale keeps zeros' + +# === Alternate form (#) keeps a trailing decimal point === +assert f'{D("5"):#g}' == '5.', 'alternate g keeps point' +assert f'{D("5"):#e}' == '5.e+0', 'alternate e keeps point' +assert f'{D("5"):#.0f}' == '5.', 'alternate fixed keeps point' + +# === Unsupported specs raise CPython's single 'invalid format string' === +for bad in ['d', 'x', 'X', 'b', 'o', 'c', 's', ',n', '_n', ',d', 'zd', 'zs']: + try: + _ = f'{D("123"):{bad}}' + assert False, f'expected {bad!r} to raise' + except ValueError as exc: + assert str(exc) == 'invalid format string', f'{bad!r}: {exc}' + +# Invalid specs are rejected even for non-finite values (spec parsed first). +for bad in ['d', ',n']: + try: + _ = f'{D("inf"):{bad}}' + assert False, f'expected {bad!r} on inf to raise' + except ValueError as exc: + assert str(exc) == 'invalid format string', f'inf {bad!r}: {exc}' + +# === z (negative-zero coercion) is honored for Decimal === +assert f'{D("-0.001"):z.2f}' == '0.00', 'z coerces rounded negative zero' +assert f'{D("-0.001"):.2f}' == '-0.00', 'without z the minus survives' + +# === Specials ignore the `0` flag (space-filled) but honor explicit fills === +assert f'{D("NaN"):010}' == ' NaN', 'NaN 0-flag space-fills' +assert f'{D("NaN"):0>10}' == '0000000NaN', 'explicit 0 fill honored' +assert f'{D("NaN"):0=10}' == '0000000NaN', 'explicit 0= fill honored' +assert f'{D("-Infinity"):015f}' == ' -Infinity', '-Infinity 0-flag space-fills' +assert f'{D("NaN"):*>10}' == '*******NaN', 'explicit * fill honored' +assert f'{D("inf"):+010.3e}' == ' +Infinity', 'inf 0-flag with sign space-fills' +assert f'{D("Infinity"):010}' == ' Infinity', 'Infinity 0-flag space-fills' diff --git a/crates/monty/test_cases/decimal__math.py b/crates/monty/test_cases/decimal__math.py new file mode 100644 index 000000000..a007751b5 --- /dev/null +++ b/crates/monty/test_cases/decimal__math.py @@ -0,0 +1,31 @@ +from decimal import Decimal as D +import math + +# === math.floor / ceil / trunc use Decimal's __floor__/__ceil__/__trunc__ === +assert math.floor(D('1.7')) == 1, 'floor' +assert math.floor(D('-1.7')) == -2, 'floor negative' +assert math.ceil(D('1.7')) == 2, 'ceil' +assert math.ceil(D('-1.7')) == -1, 'ceil negative' +assert math.trunc(D('1.7')) == 1, 'trunc' +assert math.trunc(D('-1.7')) == -1, 'trunc toward zero' +assert math.floor(D('2')) == 2, 'floor integral' + +# === float-consuming math functions accept Decimal via __float__ === +assert math.sqrt(D(4)) == 2.0, 'math.sqrt' +assert math.isnan(D('nan')), 'math.isnan' +assert not math.isnan(D('1.5')), 'math.isnan finite' +assert math.isinf(D('-inf')), 'math.isinf' +assert math.isfinite(D('1.5')), 'math.isfinite' +assert math.log10(D(100)) == 2.0, 'math.log10' + +# === NaN / Infinity conversion errors match int() === +try: + math.floor(D('nan')) + assert False, 'expected ValueError' +except ValueError as exc: + assert str(exc) == 'cannot convert NaN to integer', 'floor NaN message' +try: + math.ceil(D('inf')) + assert False, 'expected OverflowError' +except OverflowError as exc: + assert str(exc) == 'cannot convert Infinity to integer', 'ceil inf message' diff --git a/crates/monty/test_cases/decimal__methods.py b/crates/monty/test_cases/decimal__methods.py new file mode 100644 index 000000000..aa9055f64 --- /dev/null +++ b/crates/monty/test_cases/decimal__methods.py @@ -0,0 +1,260 @@ +from decimal import Decimal as D, InvalidOperation + +# === Builtins: abs / int / float === +assert str(abs(D('-3.5'))) == '3.5', 'abs negative' +assert str(abs(D('3.5'))) == '3.5', 'abs positive' +assert str(abs(D('-inf'))) == 'Infinity', 'abs -inf' +assert int(D('1.7')) == 1, 'int truncates' +assert int(D('-1.7')) == -1, 'int truncates toward zero' +assert int(D('5')) == 5, 'int exact' +assert int(D('1E+2')) == 100, 'int from exponent form' +assert float(D('1.5')) == 1.5, 'float' +assert float(D('inf')) == float('inf'), 'float inf' + +# === round === +assert round(D('2.5')) == 2, 'round half-even down' +assert round(D('3.5')) == 4, 'round half-even up' +assert round(D('2.5')) == 2 and type(round(D('2.5'))) is int, 'round no-arg returns int' +assert str(round(D('2.675'), 2)) == '2.68', 'round to 2 places' +assert str(round(D('2.5'), 0)) == '2', 'round to 0 places returns Decimal' +assert str(round(D('123.456'), -1)) == '1.2E+2', 'round negative ndigits' +assert type(round(D('2.5'), 1)) is D, 'round with ndigits returns Decimal' +# round of specials / over-precision (CPython __round__ semantics) +assert str(round(D('nan'), 2)) == 'NaN', 'round(NaN, n) stays NaN' +try: + round(D('inf'), 2) + assert False, 'expected InvalidOperation' +except InvalidOperation as exc: + assert str(exc) == "[]", 'round(inf, n) signals InvalidOperation' +try: + round(D('1.5'), 100) + assert False, 'expected InvalidOperation' +except InvalidOperation as exc: + assert str(exc) == "[]", 'round past precision signals InvalidOperation' + +# === Predicates === +assert D('nan').is_nan() and not D('1.5').is_nan(), 'is_nan' +assert D('inf').is_infinite() and not D('1.5').is_infinite(), 'is_infinite' +assert D('1.5').is_finite() and not D('inf').is_finite() and not D('nan').is_finite(), 'is_finite' +assert D('0').is_zero() and D('-0').is_zero() and not D('1').is_zero(), 'is_zero' +assert D('-1.5').is_signed() and D('-0').is_signed() and not D('1.5').is_signed(), 'is_signed' +assert D('nan').is_qnan() and not D('nan').is_snan(), 'is_qnan / is_snan' +assert not D('1.5').is_snan(), 'is_snan always false' + +# === Methods returning Decimal === +assert str(D('9').sqrt()) == '3', 'sqrt perfect square' +assert str(D('2').sqrt()) == '1.414213562373095048801688724', 'sqrt to prec' +assert str(D('1.23456').quantize(D('0.01'))) == '1.23', 'quantize down' +assert str(D('1.5').quantize(D('1'))) == '2', 'quantize to integer (half-even)' +assert str(D('1.20').normalize()) == '1.2', 'normalize strips zeros' +assert str(D('100').normalize()) == '1E+2', 'normalize trailing integer zeros' +assert str(D('1.7').to_integral_value()) == '2', 'to_integral_value rounds' +assert str(D('-1.5').copy_abs()) == '1.5', 'copy_abs' +assert str(D('1.5').copy_negate()) == '-1.5', 'copy_negate' +assert str(D('1.5').copy_sign(D('-2'))) == '-1.5', 'copy_sign' +assert str(D('1000').log10()) == '3', 'log10' +assert str(D('1').exp()) == '2.718281828459045235360287471', 'exp' + +# === ln (natural log) === +assert str(D('1').ln()) == '0', 'ln of 1 is exactly 0' +assert str(D('nan').ln()) == 'NaN', 'ln of NaN propagates quietly' +try: + D('-1').ln() + assert False, 'expected InvalidOperation' +except InvalidOperation as exc: + assert str(exc) == "[]", 'ln of a negative signals InvalidOperation' + +# === quantize: infinities and precision overflow === +assert str(D('inf').quantize(D('inf'))) == 'Infinity', 'inf.quantize(inf) keeps infinity' +assert str(D('-inf').quantize(D('inf'))) == '-Infinity', 'inf.quantize(inf) keeps sign' +try: + D('inf').quantize(D('1')) + assert False, 'expected InvalidOperation' +except InvalidOperation as exc: + assert str(exc) == "[]", 'finite/infinite quantize is invalid' +try: + D('1e100').quantize(D('1e-100')) + assert False, 'expected InvalidOperation' +except InvalidOperation as exc: + assert str(exc) == "[]", 'quantize past precision is invalid' + +# === adjusted === +assert D('1.234').adjusted() == 0, 'adjusted fractional' +assert D('1E+5').adjusted() == 5, 'adjusted exponent' +assert D('100').adjusted() == 2, 'adjusted integer' +# A zero coefficient counts as one digit, so adjusted == exponent for any zero. +assert D('0').adjusted() == 0, 'adjusted zero' +assert D('0.00').adjusted() == -2, 'adjusted zero with scale' +assert D('0E+5').adjusted() == 5, 'adjusted zero with positive exponent' + +# === as_tuple (DecimalTuple namedtuple) === +t = D('1.20').as_tuple() +assert (t.sign, t.digits, t.exponent) == (0, (1, 2, 0), -2), 'as_tuple fields' +assert t[0] == 0 and t[1] == (1, 2, 0) and t[2] == -2, 'as_tuple indexing' +neg = D('-0.5').as_tuple() +assert (neg.sign, neg.digits, neg.exponent) == (1, (5,), -1), 'as_tuple negative' +inf_t = D('inf').as_tuple() +assert (inf_t.sign, inf_t.digits, inf_t.exponent) == (0, (0,), 'F'), 'as_tuple infinity' +# repr uses the CPython class name `DecimalTuple`, not the snake_case default. +assert repr(t) == 'DecimalTuple(sign=0, digits=(1, 2, 0), exponent=-2)', 'as_tuple repr class name' + +# === Tuple/list constructor === +assert str(D((0, (1, 2, 0), -2))) == '1.20', 'tuple constructor' +assert str(D((1, (5,), 0))) == '-5', 'tuple constructor negative' +assert str(D((0, (), 'F'))) == 'Infinity', 'tuple constructor infinity' +assert str(D((0, (3, 1, 4), -2))) == '3.14', 'tuple constructor decimal' +# `bool` is accepted everywhere an `int` is (sign, digits, exponent). +assert str(D((False, (1,), 0))) == '1', 'tuple constructor bool sign' +assert str(D((0, (True,), 0))) == '1', 'tuple constructor bool digit' +assert str(D((0, (1,), True))) == '1E+1', 'tuple constructor bool exponent' + +# round-trip: as_tuple -> Decimal +original = D('-12.34') +assert D(original.as_tuple()) == original, 'as_tuple round-trips through constructor' + +# === Tuple constructor errors === +try: + D([1, 2]) + assert False, 'expected ValueError' +except ValueError as exc: + assert str(exc) == 'argument must be a sequence of length 3', 'wrong length message' + +try: + D((2, (1,), 0)) + assert False, 'expected ValueError' +except ValueError as exc: + assert str(exc) == 'sign must be an integer with the value 0 or 1', 'bad sign message' + +try: + D((0, 5, 0)) + assert False, 'expected ValueError' +except ValueError as exc: + assert str(exc) == 'coefficient must be a tuple of digits', 'non-sequence coefficient message' + +try: + D((0, (10,), 0)) + assert False, 'expected ValueError' +except ValueError as exc: + assert str(exc) == 'coefficient must be a tuple of digits', 'out-of-range digit message' + +try: + D((0, (1,), 1.5)) + assert False, 'expected ValueError' +except ValueError as exc: + assert str(exc) == 'exponent must be an integer', 'float exponent message' + +try: + D((0, (1,), 'd')) + assert False, 'expected ValueError' +except ValueError as exc: + assert str(exc) == "string argument in the third position must be 'F', 'n' or 'N'", 'bad marker message' + +# === int/float errors on specials === +try: + int(D('inf')) + assert False, 'expected OverflowError' +except OverflowError as exc: + assert str(exc) == 'cannot convert Infinity to integer', 'int inf message' + +try: + int(D('nan')) + assert False, 'expected ValueError' +except ValueError as exc: + assert str(exc) == 'cannot convert NaN to integer', 'int nan message' + +# === Method arity: a spurious argument raises (no refcount leak) === +# `as_tuple` / `copy_abs` allocate a heap result; the argument count must be +# checked *before* computing, or the result leaks (and panics under +# memory-model-checks). The error is qualified `Decimal.()` like CPython. +try: + D('1.20').as_tuple(5) + assert False, 'expected TypeError' +except TypeError as exc: + assert str(exc) == 'Decimal.as_tuple() takes no arguments (1 given)', 'as_tuple arity' + +try: + D('-1.5').copy_abs(5) + assert False, 'expected TypeError' +except TypeError as exc: + assert str(exc) == 'Decimal.copy_abs() takes no arguments (1 given)', 'copy_abs arity' + +try: + D('1').is_nan(5) + assert False, 'expected TypeError' +except TypeError as exc: + assert str(exc) == 'Decimal.is_nan() takes no arguments (1 given)', 'predicate arity' + +# === Per-call rounding= on quantize (all eight modes) === +import decimal + +cents = D('0.01') +for mode, expected in [ + (decimal.ROUND_CEILING, '7.33'), + (decimal.ROUND_FLOOR, '7.32'), + (decimal.ROUND_DOWN, '7.32'), + (decimal.ROUND_UP, '7.33'), + (decimal.ROUND_HALF_UP, '7.33'), + (decimal.ROUND_HALF_DOWN, '7.32'), + (decimal.ROUND_HALF_EVEN, '7.32'), + (decimal.ROUND_05UP, '7.32'), +]: + assert str(D('7.325').quantize(cents, rounding=mode)) == expected, f'quantize {mode}' +assert str(D('-7.325').quantize(cents, rounding=decimal.ROUND_CEILING)) == '-7.32', 'ceiling toward +inf' +assert str(D('-7.325').quantize(cents, rounding=decimal.ROUND_FLOOR)) == '-7.33', 'floor toward -inf' +assert str(D('7.505').quantize(cents, rounding=decimal.ROUND_05UP)) == '7.51', '05up on kept 0' +assert str(D('7.325').quantize(cents)) == '7.32', 'quantize default HALF_EVEN' +assert str(D('7.325').quantize(cents, None)) == '7.32', 'explicit rounding=None' + +# rounding= also works positionally and on to_integral_value +assert str(D('7.325').quantize(cents, decimal.ROUND_UP)) == '7.33', 'positional rounding' +assert str(D('7.7').to_integral_value(rounding=decimal.ROUND_FLOOR)) == '7', 'to_integral_value rounding' +assert str(D('-7.1').to_integral_value(rounding=decimal.ROUND_CEILING)) == '-7', 'to_integral ceiling' + +# An invalid rounding value (bad string or non-string) raises CPython's TypeError. +for bad in ['ROUND_SIDEWAYS', 3]: + try: + D('1').quantize(cents, rounding=bad) + assert False, 'expected TypeError for bad rounding' + except TypeError as exc: + assert str(exc) == ( + 'valid values for rounding are:\n [ROUND_CEILING, ROUND_FLOOR, ROUND_UP, ROUND_DOWN,\n' + ' ROUND_HALF_UP, ROUND_HALF_DOWN, ROUND_HALF_EVEN,\n ROUND_05UP]' + ), f'bad rounding message: {exc}' + +# === to_integral_value keeps positive-exponent values unchanged === +assert str(D('1E+30').to_integral_value()) == '1E+30', 'positive exponent unchanged' +assert str(D('1E+300').to_integral_value()) == '1E+300', 'huge positive exponent unchanged' + +# === sqrt at extreme magnitudes === +assert str(D('1E+1000').sqrt()) == '1E+500', 'sqrt of huge magnitude' +assert str(D('1E-1000').sqrt()) == '1E-500', 'sqrt of tiny magnitude' +assert str(D(2).sqrt()) == '1.414213562373095048801688724', 'sqrt(2) correctly rounded' + +# === Transcendentals: exact CPython digits === +assert str(D(2).ln()) == '0.6931471805599453094172321215', 'ln(2)' +assert str(D(3).log10()) == '0.4771212547196624372950279033', 'log10(3)' +assert str(D(1).exp()) == '2.718281828459045235360287471', 'exp(1)' +assert str(D(-40).exp()) == '4.248354255291588995329234783E-18', 'exp(-40)' +assert str(D(0).ln()) == '-Infinity', 'ln(0)' +assert str(D('-0').ln()) == '-Infinity', 'ln(-0)' +assert str(D(0).log10()) == '-Infinity', 'log10(0)' + +# === exp() overflow raises (never saturates or aborts) === +for operand in ['1E+30', '1E16000']: + try: + D(operand).exp() + assert False, 'expected Overflow from exp' + except decimal.Overflow as exc: + assert str(exc) == "[]", f'exp({operand}) message' + +# === Signaling NaN through conversions === +try: + float(D('sNaN')) + assert False, 'expected ValueError from float(sNaN)' +except ValueError as exc: + assert str(exc) == 'cannot convert signaling NaN to float', 'float sNaN message' +try: + int(D('sNaN')) + assert False, 'expected ValueError from int(sNaN)' +except ValueError as exc: + assert str(exc) == 'cannot convert NaN to integer', 'int sNaN message' diff --git a/crates/monty/tests/binary_serde.rs b/crates/monty/tests/binary_serde.rs index cf91016c0..34263ab60 100644 --- a/crates/monty/tests/binary_serde.rs +++ b/crates/monty/tests/binary_serde.rs @@ -172,6 +172,102 @@ fn run_progress_dump_load_multiple_calls() { assert_eq!(result.into_complete().unwrap(), MontyObject::Int(30)); // 10 + 20 } +#[test] +fn run_progress_dump_load_decimal_in_heap() { + // A `Decimal` held in a local survives a heap snapshot taken at an external + // call: its hand-written serde re-parses the canonical string on load, so + // post-restore arithmetic — including a raising op (`1/0`) — works and + // raises correctly. + let code = r" +from decimal import Decimal +saved = Decimal('1.50') +ext_fn(0) +out = str(saved + Decimal('2.5')) +try: + Decimal(1) / Decimal(0) + out += ' NORAISE' +except ZeroDivisionError: + out += ' caught' +out +" + .to_owned(); + let runner = MontyRun::new(code, "test.py", vec![]).unwrap(); + let progress = runner.start(vec![], NoLimitTracker, PrintWriter::Stdout).unwrap(); + let progress = resolve_name_lookups(progress).unwrap(); + + // Dump while paused at `ext_fn(0)` — the heap (with `saved`) is serialized. + let bytes = progress.dump().unwrap(); + let loaded: RunProgress = RunProgress::load(&bytes).unwrap(); + + let call = loaded.into_function_call().expect("paused at ext_fn"); + assert_eq!(call.function_name, "ext_fn"); + let result = call.resume(MontyObject::Int(0), PrintWriter::Stdout).unwrap(); + assert_eq!( + result.into_complete().unwrap(), + MontyObject::String("4.00 caught".to_owned()) + ); +} + +#[test] +fn run_progress_load_rejects_corrupt_decimal() { + // A snapshot is untrusted input. A hand-tampered snapshot carrying a + // `Decimal` whose canonical string no longer parses must be *rejected* on + // load — `Decimal`'s `Deserialize` re-validates through the same parser as + // `Decimal(str)`. The valid `1e16000` (serialized canonically as + // `1E+16000`, which the lowercase source spelling never collides with) + // loads fine; patching only its heap bytes to the same-length malformed + // `+E+16000` (postcard framing intact) must make load fail. + let code = r" +from decimal import Decimal +saved = Decimal('1e16000') +ext_fn(0) +saved +" + .to_owned(); + let runner = MontyRun::new(code, "test.py", vec![]).unwrap(); + let progress = runner.start(vec![], NoLimitTracker, PrintWriter::Stdout).unwrap(); + let progress = resolve_name_lookups(progress).unwrap(); + let bytes = progress.dump().unwrap(); + + // The untampered snapshot loads AND resumes to completion (resuming, not + // just loading, both proves the restored heap value is usable and tears + // the loaded refs down properly — under `memory-model-checks` a loaded + // snapshot that is merely dropped trips the bare-`Ref` drop guard). + let loaded = RunProgress::::load(&bytes).expect("untampered snapshot loads"); + let call = loaded.into_function_call().expect("paused at ext_fn"); + let result = call.resume(MontyObject::Int(0), PrintWriter::Stdout).unwrap(); + assert_eq!( + result.into_complete().unwrap(), + MontyObject::Decimal("1E+16000".to_owned()) + ); + + // Patch the heap value's canonical string to a malformed literal. + let tampered = replace_all(&bytes, b"1E+16000", b"+E+16000"); + assert_ne!(tampered, bytes, "expected the canonical decimal string in the snapshot"); + assert!( + RunProgress::::load(&tampered).is_err(), + "corrupt decimal string must be rejected on load" + ); +} + +/// Replaces every non-overlapping occurrence of `from` with the equal-length +/// `to` in `haystack`. Equal length keeps postcard's length-prefixed framing +/// valid, so the only change is the bytes themselves. +fn replace_all(haystack: &[u8], from: &[u8], to: &[u8]) -> Vec { + assert_eq!(from.len(), to.len(), "replacement must preserve framing"); + let mut out = haystack.to_vec(); + let mut i = 0; + while i + from.len() <= out.len() { + if &out[i..i + from.len()] == from { + out[i..i + from.len()].copy_from_slice(to); + i += from.len(); + } else { + i += 1; + } + } + out +} + #[test] fn run_progress_complete_roundtrip() { // When execution completes, we can still dump/load the Complete variant diff --git a/crates/monty/tests/decimal_guards.rs b/crates/monty/tests/decimal_guards.rs new file mode 100644 index 000000000..1ab6cf8d7 --- /dev/null +++ b/crates/monty/tests/decimal_guards.rs @@ -0,0 +1,122 @@ +//! Monty-specific `Decimal` sandbox guards that diverge from CPython and so +//! cannot live in the dual-run `test_cases/` (CPython, with arbitrary +//! precision, accepts these inputs instead of erroring). +//! +//! The guards under test: the constructor's coefficient/payload digit cap +//! (`DECIMAL_MAX_DIGITS` = 4300, mirroring `INT_MAX_STR_DIGITS`), the C-module +//! exponent literal bounds, and the resource-tracker charge on materialising a +//! huge-exponent integral value (`int()` / `hash()`). + +use std::time::Duration; + +use monty::{ExcType, LimitedTracker, MontyObject, MontyRun, PrintWriter, ResourceLimits}; + +/// Runs `code` with no resource limits and returns the exception, or panics if +/// it unexpectedly succeeds. +fn run_expect_error(code: &str) -> (ExcType, String) { + let run = MontyRun::new(code.to_owned(), "test.py", vec![]).expect("should parse"); + let exc = run.run_no_limits(vec![]).expect_err("expected an exception"); + (exc.exc_type(), exc.message().unwrap_or_default().to_owned()) +} + +/// Runs `code` with no resource limits and returns the result, panicking on error. +fn run_ok(code: &str) -> MontyObject { + let run = MontyRun::new(code.to_owned(), "test.py", vec![]).expect("should parse"); + run.run_no_limits(vec![]).expect("should run") +} + +/// A coefficient past the 4300-digit cap is rejected with the Monty-specific +/// `ValueError` — from a string literal, an `int`, and a NaN payload alike. +/// CPython accepts all of these (documented divergence). +#[test] +fn digit_cap_rejected() { + for expr in [ + "Decimal('1' * 4301)", + "Decimal(10 ** 4301)", + "Decimal('NaN' + '1' * 4301)", + "Decimal((0, (1,) * 4301, 0))", + ] { + let code = format!("from decimal import Decimal\n{expr}"); + let (exc_type, msg) = run_expect_error(&code); + assert_eq!(exc_type, ExcType::ValueError, "{expr}"); + assert_eq!(msg, "Decimal value exceeds the limit of 4300 digits", "{expr}"); + } +} + +/// Values at the cap still construct: the guard must not over-reject. +#[test] +fn digit_cap_boundary_accepted() { + assert_eq!( + run_ok("from decimal import Decimal\nlen(str(Decimal('9' * 4300)))"), + MontyObject::Int(4300) + ); + // Leading zeros are stripped before the cap is applied, so a long-but-thin + // literal is fine. + assert_eq!( + run_ok("from decimal import Decimal\nstr(Decimal('0' * 10000 + '7'))"), + MontyObject::String("7".to_owned()) + ); +} + +/// Exponent literals follow the C module's 64-bit bounds: within them huge +/// exponents construct and round-trip; beyond them `InvalidOperation` is +/// raised exactly as in CPython. +#[test] +fn exponent_literal_bounds() { + assert_eq!( + run_ok("from decimal import Decimal\nstr(Decimal('1E+425000000'))"), + MontyObject::String("1E+425000000".to_owned()) + ); + assert_eq!( + run_ok("from decimal import Decimal\nstr(Decimal('1E+999999999999999998'))"), + MontyObject::String("1E+999999999999999998".to_owned()) + ); + let (exc_type, msg) = run_expect_error("from decimal import Decimal\nDecimal('1E+1000000000000000000')"); + assert_eq!(exc_type, ExcType::DecimalInvalidOperation); + assert_eq!(msg, "[]"); +} + +/// Materialising a huge-exponent integral value (`int(d)` needs +/// `coeff · 10^exp`) is charged to the resource tracker, so under a memory +/// limit it raises `MemoryError` instead of allocating gigabytes. CPython +/// computes it (documented divergence). +#[test] +fn huge_exponent_int_hits_memory_limit() { + let code = "from decimal import Decimal\nint(Decimal('1E+100000000'))"; + let runner = MontyRun::new(code.to_owned(), "test.py", vec![]).unwrap(); + let limits = ResourceLimits::new() + .max_memory(1024 * 1024) + .max_duration(Duration::from_secs(30)); + let exc = runner + .run(vec![], LimitedTracker::new(limits), PrintWriter::Stdout) + .expect_err("huge power-of-ten materialisation must be bounded by the memory limit"); + assert_eq!(exc.exc_type(), ExcType::MemoryError); +} + +/// The same guard covers `hash()`, which also materialises the exact integer. +#[test] +fn huge_exponent_hash_hits_memory_limit() { + let code = "from decimal import Decimal\nhash(Decimal('1E+100000000'))"; + let runner = MontyRun::new(code.to_owned(), "test.py", vec![]).unwrap(); + let limits = ResourceLimits::new() + .max_memory(1024 * 1024) + .max_duration(Duration::from_secs(30)); + let exc = runner + .run(vec![], LimitedTracker::new(limits), PrintWriter::Stdout) + .expect_err("huge power-of-ten materialisation must be bounded by the memory limit"); + assert_eq!(exc.exc_type(), ExcType::MemoryError); +} + +/// A `Decimal` operand of arithmetic promoted from a too-large `int` hits the +/// same digit cap as construction (`Decimal(1) + 10**100` works; `+ 10**4301` +/// raises). +#[test] +fn promoted_int_operand_capped() { + assert_eq!( + run_ok("from decimal import Decimal\nstr(Decimal(1) + 10**100)"), + MontyObject::String("1.000000000000000000000000000E+100".to_owned()) + ); + let (exc_type, msg) = run_expect_error("from decimal import Decimal\nDecimal(1) + 10**4301"); + assert_eq!(exc_type, ExcType::ValueError); + assert_eq!(msg, "Decimal value exceeds the limit of 4300 digits"); +} diff --git a/crates/monty/tests/inputs.rs b/crates/monty/tests/inputs.rs index 2b7d944b7..e04b264c4 100644 --- a/crates/monty/tests/inputs.rs +++ b/crates/monty/tests/inputs.rs @@ -84,6 +84,67 @@ fn input_bytes() { assert_eq!(result, MontyObject::Bytes(vec![1, 2, 3])); } +// === Decimal Input Tests === + +#[test] +fn input_decimal() { + // A host `Decimal` crosses in as its canonical string and back unchanged + // (the scale `1.20` is preserved, not normalised to `1.2`). + let ex = MontyRun::new("x".to_owned(), "test.py", vec!["x".to_owned()]).unwrap(); + let result = ex.run_no_limits(vec![MontyObject::Decimal("1.20".to_owned())]).unwrap(); + assert_eq!(result, MontyObject::Decimal("1.20".to_owned())); +} + +#[test] +fn input_decimal_arithmetic() { + let ex = MontyRun::new("x * 2".to_owned(), "test.py", vec!["x".to_owned()]).unwrap(); + let result = ex.run_no_limits(vec![MontyObject::Decimal("1.5".to_owned())]).unwrap(); + assert_eq!(result, MontyObject::Decimal("3.0".to_owned())); +} + +#[test] +fn input_decimal_oversized_rejected() { + // A host `Decimal` is untrusted boundary input: a coefficient past the + // sandbox digit cap (4300 digits — `DECIMAL_MAX_DIGITS`) must be rejected + // crossing *into* the sandbox, exactly as in-sandbox construction rejects + // the same literal. + let ex = MontyRun::new("x".to_owned(), "test.py", vec!["x".to_owned()]).unwrap(); + let err = ex + .run_no_limits(vec![MontyObject::Decimal("1".repeat(4301))]) + .unwrap_err(); + assert_eq!(err.exc_type(), ExcType::RuntimeError); + assert_eq!(err.message(), Some("invalid input type: Decimal")); +} + +#[test] +fn input_decimal_malformed_rejected() { + // A string that is not a decimal literal at all must also be rejected at + // the boundary, not degrade to a NaN or a corrupt value. + let ex = MontyRun::new("x".to_owned(), "test.py", vec!["x".to_owned()]).unwrap(); + let err = ex + .run_no_limits(vec![MontyObject::Decimal("not-a-number".to_owned())]) + .unwrap_err(); + assert_eq!(err.exc_type(), ExcType::RuntimeError); + assert_eq!(err.message(), Some("invalid input type: Decimal")); +} + +#[test] +fn input_decimal_extremes_accepted() { + // Values anywhere in the C-module literal range cross losslessly — + // including huge exponents and specials with payloads. + for literal in ["1E-32768", "1E+425000000", "0E-10", "-sNaN123", "-0"] { + let ex = MontyRun::new("x".to_owned(), "test.py", vec!["x".to_owned()]).unwrap(); + let result = ex + .run_no_limits(vec![MontyObject::Decimal(literal.to_owned())]) + .unwrap(); + assert_eq!( + result, + MontyObject::Decimal(literal.to_owned()), + "{literal} should round-trip" + ); + } +} + #[test] fn input_list() { let ex = MontyRun::new("x".to_owned(), "test.py", vec!["x".to_owned()]).unwrap(); diff --git a/crates/monty/tests/json_serde.rs b/crates/monty/tests/json_serde.rs index 0f0df62fa..bce5b84ce 100644 --- a/crates/monty/tests/json_serde.rs +++ b/crates/monty/tests/json_serde.rs @@ -34,6 +34,24 @@ fn json_output_primitives() { assert_snapshot!(to_json(&MontyObject::None), @r#""None""#); } +#[test] +fn json_output_decimal() { + // A Decimal crosses the boundary as its canonical string under the derived + // externally-tagged serde (`{"Decimal":""}`), round-tripping exactly. + assert_snapshot!( + to_json(&eval("from decimal import Decimal\nDecimal('1.20')")), + @r#"{"Decimal":"1.20"}"# + ); + assert_snapshot!( + to_json(&eval("from decimal import Decimal\nDecimal('1E+5')")), + @r#"{"Decimal":"1E+5"}"# + ); + assert_snapshot!( + to_json(&eval("from decimal import Decimal\nDecimal(1) / Decimal(3)")), + @r#"{"Decimal":"0.3333333333333333333333333333"}"# + ); +} + #[test] fn json_output_list() { assert_snapshot!( diff --git a/limitations/decimal.md b/limitations/decimal.md new file mode 100644 index 000000000..1b25c0e43 --- /dev/null +++ b/limitations/decimal.md @@ -0,0 +1,147 @@ +# `decimal` module + +Monty's `decimal.Decimal` is a Rust port of CPython's `_pydecimal` numeric +core, running under a **fixed context** — exactly CPython's default: +`prec=28`, `rounding=ROUND_HALF_EVEN`, `Emax=999999`, `Emin=-999999`, +`capitals=1`, `clamp=0`. Arithmetic results are digit-for-digit identical to +CPython's; the divergences are the missing context machinery, a reduced +method surface, and sandbox input caps. + +## Module attributes + +Implemented: `Decimal`, the full exception taxonomy (see *Exceptions*), and +the eight `ROUND_*` string constants. + +Not implemented (`AttributeError`; `from decimal import …` raises +`ImportError`): `getcontext`, `setcontext`, `localcontext`, `Context`, +`DefaultContext`, `BasicContext`, `ExtendedContext`, `IEEEContext`, +`IEEE_CONTEXT_MAX_BITS`, `DecimalTuple`, `MAX_PREC`, `MAX_EMAX`, `MIN_EMIN`, +`MIN_ETINY`, `HAVE_THREADS`, `HAVE_CONTEXTVAR`. + +## Fixed context + +- `prec` cannot be changed — every operation rounds to 28 significant digits. +- The rounding mode is per-call, not global: `quantize` and + `to_integral_value` accept CPython's `rounding=` argument (all eight + modes); everything else — `__format__` included — always rounds + `ROUND_HALF_EVEN`. +- No `context=` argument anywhere: methods that accept one in CPython and the + `Decimal(value, context)` constructor raise `TypeError` — even for + `Decimal('3', None)`, which CPython accepts — with generic arity/keyword + wording rather than CPython's `optional argument must be a context`. +- No signal flags or traps. Trap behaviour is frozen at CPython's defaults: + `InvalidOperation`, `DivisionByZero`, and `Overflow` always raise; the + other signals never do (so float ↔ `Decimal` mixing in construction and + comparison — CPython's armable `FloatOperation` — is always silent). + +## Input size caps + +CPython accepts unboundedly large constructor inputs; Monty caps *unrounded +constructor operands* (arithmetic results stay within `prec` and `Emax` +anyway): + +- Coefficients and NaN payloads are capped at 4300 digits (the `int` ↔ `str` + conversion limit) on every input path — string literals, `Decimal(int)`, + the tuple form, host/snapshot input, and `int` operands promoted by + arithmetic or methods — raising + `ValueError: Decimal value exceeds the limit of 4300 digits`. +- `int(d)` / `hash(d)` of a huge-exponent integral value + (`Decimal('1E+100000000')`) is charged to the resource tracker, so under a + memory limit it raises `MemoryError` where CPython builds the integer. + +Exponent literals are not additionally capped: the C module's own 64-bit +bounds apply, matching CPython. + +## Construction + +Accepts `Decimal(str | int | float | Decimal)` and +`Decimal((sign, digits, exponent))` (the `as_tuple` form), with `value` +usable as a keyword. + +- `Decimal.from_float(x)` / `from_number(x)` are not implemented — call + `Decimal(x)` directly for the identical exact value. +- ASCII digits only: non-ASCII decimal digits CPython accepts + (`Decimal('٥')`) raise `InvalidOperation([ConversionSyntax])`. + +## Methods + +Implemented: `quantize`, `normalize`, `to_integral_value`, `sqrt`, `ln`, +`log10`, `exp`, `as_tuple`, `adjusted`, `copy_abs`, `copy_negate`, +`copy_sign`, the `is_*` predicates, plus the builtins `int()`, `float()`, +`round()`, `abs()`, `pow()` (2- and 3-argument), `divmod()`, `hash()`, +`__format__`, and the `math` module's `floor` / `ceil` / `trunc` / +float-consuming functions. + +- `Decimal.__name__` (and `type(d).__name__`) is `'decimal.Decimal'`, not + CPython's bare `'Decimal'` — consistent with Monty's qualified datetime + type names. +- `as_tuple()` returns a named tuple with by-name and by-index access, but + `DecimalTuple` itself is not importable (see *Module attributes*) and Monty + has no per-class named-tuple identity (`type(x).__name__` is + `'namedtuple'`); compare fields directly. +- Not implemented (raise `AttributeError`): `compare`, `compare_signal`, + `compare_total`, `compare_total_mag`, `max`, `min`, `max_mag`, `min_mag`, + `next_minus`, `next_plus`, `next_toward`, `number_class`, `radix`, + `canonical`, `conjugate`, `copy`, `is_canonical`, `is_normal`, + `is_subnormal`, `logb`, `logical_and`, `logical_or`, `logical_xor`, + `logical_invert`, `rotate`, `scaleb`, `shift`, `fma`, `remainder_near`, + `same_quantum`, `to_eng_string`, `to_integral_exact`, `from_float`, + `from_number`, and the attributes `real` / `imag` / `as_integer_ratio`. + +## Comparison and hashing + +Comparisons against `int`, `bool`, `float`, and `Decimal` have CPython's +exact semantics (NaN and sNaN included), with these divergences: + +- `Decimal(1) in range(3)` is `False` (CPython: `True`) — `range` membership + has no `Decimal` fast path. +- Hashing uses Monty's runtime hash, not CPython's `_PyHASH_MODULUS`, so raw + `hash(Decimal(...))` values differ from CPython but stay cross-type + consistent in-sandbox: `hash(Decimal(5)) == hash(5)` and + `hash(Decimal(f)) == hash(f)`, so equal numbers share a `dict`/`set` slot. + +## Exceptions + +The full CPython taxonomy is importable (`DecimalException`, +`InvalidOperation`, `ConversionSyntax`, `DivisionImpossible`, +`DivisionUndefined`, `InvalidContext`, `DivisionByZero`, `Overflow`, +`Inexact`, `Rounded`, `Subnormal`, `Clamped`, `Underflow`, +`FloatOperation`), with CPython's multi-parent relationships +(`FloatOperation` is a `TypeError`; `DivisionByZero` / `DivisionUndefined` +are `ZeroDivisionError`s; all are `ArithmeticError`s). + +- Only `InvalidOperation`, `DivisionByZero`, and `Overflow` are ever raised + (the default-trapped signals); the rest are catchable but, since traps + cannot be armed, never raise in practice. +- `InvalidOperation` subtypes are never raised directly — like CPython, the + condition appears in the *message* (`[]`), + so `ConversionSyntax` etc. are caught only via `InvalidOperation`. +- `exc.args[0]` is that message *string*, not CPython's list of condition + classes. +- `type(exc).__name__` is the qualified `'decimal.InvalidOperation'` etc., + not CPython's bare `'InvalidOperation'` — the same pattern as + `Decimal.__name__` above. + +## Formatting + +`str()`, `repr()`, and `__format__` follow CPython's spec mini-language for +`Decimal` (including `n`, which behaves as `g` — Monty has no locale), with +one divergence: the `e`/`E`/`f`/`%` presentations charge fraction-padding to +the resource tracker, so an absurd precision (`f'{Decimal("1"):.{10**9}e}'`) +raises `MemoryError` under a memory limit where CPython builds the giant +string. + +## Host boundary + +A `Decimal` crosses the Monty ↔ host boundary losslessly as its canonical +string (exposed by `pydantic_monty` as `decimal.Decimal`, by +`@pydantic/monty` as a tagged string). + +- Boundary equality/hashing is string-based: `Decimal('1.2')` and + `Decimal('1.20')` are *distinct* at the boundary though equal in-sandbox. +- A host `Decimal` over the 4300-digit cap (see *Input size caps*) is + rejected crossing into the sandbox, not truncated. +- A host-raised `decimal.*` exception re-enters the sandbox as its nearest + builtin ancestor (`ArithmeticError`, or `TypeError` for `FloatOperation`), + so `except decimal.Underflow:` cannot catch it; sandbox-raised `decimal` + exceptions surfacing to the host keep their exact class. diff --git a/limitations/modules.md b/limitations/modules.md index fe768514a..de5ed41e8 100644 --- a/limitations/modules.md +++ b/limitations/modules.md @@ -10,6 +10,7 @@ and no way for sandboxed code to load additional modules. | ---------- | ------------------------------------ | | `asyncio` | [asyncio.md](asyncio.md) | | `datetime` | [datetime.md](datetime.md) | +| `decimal` | [decimal.md](decimal.md) | | `json` | [json.md](json.md) | | `math` | [math.md](math.md) | | `os` | [os.md](os.md) | @@ -29,7 +30,7 @@ Common modules that are *not* importable in Monty (non-exhaustive): `defaultdict`, `Counter`, `OrderedDict`, `deque`; `namedtuple` is exposed as a builtin, not via `collections`), `contextlib`, `copy`, `csv`, `ctypes`, `dataclasses` (the `@dataclass` decorator is built in; the -module is not importable), `decimal`, `enum`, `fractions`, `functools`, +module is not importable), `enum`, `fractions`, `functools`, `hashlib`, `heapq`, `hmac`, `http`, `inspect`, `io`, `itertools`, `logging`, `multiprocessing`, `operator`, `pickle`, `queue`, `random`, `socket`, `string`, `struct`, `subprocess`, `tempfile`, `threading`, diff --git a/pyproject.toml b/pyproject.toml index ef5c7d59f..71b7d235b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -90,6 +90,10 @@ known-first-party = ["pydantic_monty"] "F403", # unable to detect undefined names "F821", # Undefined name ] +"crates/monty-typeshed/custom/**/*.pyi" = [ + "F403", # unable to detect undefined names + "F821", # Undefined name (stubs forward-reference their own classes) +] "crates/monty-type-checking/tests/good_types.py" = [ "F704", # await outside function - needed for testing top-level async/await ] @@ -185,7 +189,7 @@ exclude = [ [tool.codespell] # Ref: https://github.com/codespell-project/codespell#using-a-config-file -ignore-words-list = 'crate,NotIn,ser,IST,Nd' +ignore-words-list = 'crate,NotIn,ser,IST,Nd,ans' skip = [ "Cargo.lock", "crates/monty-js/package-lock.json",