Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
39 changes: 39 additions & 0 deletions crates/monty-js/__test__/types.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// =============================================================================
Expand Down
17 changes: 17 additions & 0 deletions crates/monty-js/src/convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ pub fn monty_to_js<'e>(obj: &MontyObject, env: &'e Env) -> Result<JsMontyObject<
MontyObject::DateTime(datetime) => 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 {
Expand Down Expand Up @@ -304,6 +305,16 @@ fn create_js_datetime<'e>(datetime: &MontyDateTime, env: &'e Env) -> Result<Unkn
obj.into_unknown(env)
}

/// Creates a JS object representing a Decimal: `{ __monty_type__: 'Decimal',
/// value: '1.23' }` — JS has no decimal type, so it crosses as its canonical
/// string (round-trips through `Decimal(str)` back in the sandbox).
fn create_js_decimal_marker<'e>(decimal_str: &str, env: &'e Env) -> Result<Unknown<'e>> {
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<Unknown<'e>> {
let mut obj = Object::new(env)?;
Expand Down Expand Up @@ -624,6 +635,12 @@ fn js_marked_object_to_monty(obj: &Object, monty_type: &str, env: Env) -> Result
offset_seconds: obj.get_named_property::<i32>("offsetSeconds")?,
name: obj.get_named_property::<Option<String>>("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")?;
Expand Down
18 changes: 18 additions & 0 deletions crates/monty-js/ts/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,24 @@ export const PYTHON_EXC_NAMES: ReadonlySet<string> = 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',
])

/**
Expand Down
4 changes: 4 additions & 0 deletions crates/monty-proto/proto/monty/v1/monty.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down
6 changes: 6 additions & 0 deletions crates/monty-proto/src/wire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

// ============================================================================
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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))
}
Expand Down Expand Up @@ -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(|_| {
Expand Down
5 changes: 5 additions & 0 deletions crates/monty-proto/tests/differential.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ fn corpus() -> Vec<MontyObject> {
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),
Expand Down Expand Up @@ -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(),
Expand Down
7 changes: 6 additions & 1 deletion crates/monty-proto/tests/oracle/monty.v1.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<monty_object::Kind>,
}
Expand Down Expand Up @@ -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)]
Expand Down
6 changes: 6 additions & 0 deletions crates/monty-proto/tests/roundtrip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
13 changes: 13 additions & 0 deletions crates/monty-python/src/convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<PyBaseException>() {
Ok(exc_to_monty_object(exc))
} else if is_dataclass(obj) {
Expand Down Expand Up @@ -236,6 +239,7 @@ fn round_trip_type_table(py: Python<'_>) -> PyResult<&'static Vec<(Py<PyAny>, Mo
MontyType::DateTime,
MontyType::TimeDelta,
MontyType::TimeZone,
MontyType::Decimal,
MontyType::RePattern,
MontyType::ReMatch,
MontyType::TextIOWrapper,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -435,6 +440,7 @@ fn type_object_to_py(py: Python<'_>, t: MontyType) -> PyResult<Py<PyAny>> {
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()),
Expand Down Expand Up @@ -638,6 +644,13 @@ fn get_datetime_timezone_utc(py: Python<'_>) -> PyResult<&Py<PyAny>> {
})
}

/// Cached import of the host `decimal.Decimal` class.
fn get_decimal(py: Python<'_>) -> PyResult<&Bound<'_, PyAny>> {
static DECIMAL: PyOnceLock<Py<PyAny>> = PyOnceLock::new();

DECIMAL.import(py, "decimal", "Decimal")
}

/// Cached import of `collections.namedtuple` function.
fn get_namedtuple(py: Python<'_>) -> PyResult<&Bound<'_, PyAny>> {
static NAMEDTUPLE: PyOnceLock<Py<PyAny>> = PyOnceLock::new();
Expand Down
32 changes: 32 additions & 0 deletions crates/monty-python/src/exceptions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Comment on lines +558 to +560

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve decimal exception types from Python callbacks

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

Useful? React with 👍 / 👎.

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.<name>` 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)
}
}

Expand Down
58 changes: 58 additions & 0 deletions crates/monty-python/tests/test_decimal.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading