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
16 changes: 16 additions & 0 deletions crates/monty-js/__test__/async.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,22 @@ test('runMontyAsync with args and kwargs', async (t) => {
t.is(result, 'test: 3')
})

test('runMontyAsync handles OS datetime.now via externalFunctions', async (t) => {
const m = new Monty(`
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
[now.year, now.month, now.day, now.hour, now.minute, now.second, now.microsecond]
`)

const result = await runMontyAsync(m, {
externalFunctions: {
'datetime.now': () => new Date(Date.UTC(2027, 1, 3, 4, 5, 6, 7)),
},
})

t.deepEqual(result, [2027, 2, 3, 4, 5, 6, 7000])
})

// =============================================================================
// Error handling tests
// =============================================================================
Expand Down
33 changes: 33 additions & 0 deletions crates/monty-js/__test__/external.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,39 @@ test('external function with input', (t) => {
t.is(m.run({ inputs: { x: 5 }, externalFunctions: { process } }), 50)
})

test('OS datetime.now can be handled by externalFunctions', (t) => {
const m = new Monty(`
from datetime import datetime
now = datetime.now()
[now.year, now.month, now.day, now.hour, now.minute, now.second, now.microsecond]
`)

const result = m.run({
externalFunctions: {
'datetime.now': () => new Date(2026, 0, 2, 3, 4, 5, 6),
},
})

t.deepEqual(result, [2026, 1, 2, 3, 4, 5, 6000])
})

test('OS datetime.now rejects invalid JS Date results', (t) => {
const m = new Monty(`
from datetime import datetime
now = datetime.now()
now.year
`)

const error = t.throws(() =>
m.run({
externalFunctions: {
'datetime.now': () => new Date(Number.NaN),
},
}),
)
t.true(error?.message.includes('Date method getFullYear returned an invalid number'))
})

// =============================================================================
// Error handling tests
// =============================================================================
Expand Down
47 changes: 46 additions & 1 deletion crates/monty-js/__test__/repl.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import test from 'ava'

import { MontyRepl } from '../wrapper'
import { MontyComplete, MontyRepl, MontyRuntimeError, MontySnapshot } from '../wrapper'

test('feed preserves state without replay', (t) => {
const repl = new MontyRepl()
Expand Down Expand Up @@ -32,3 +32,48 @@ test('repl dump/load roundtrip', (t) => {

t.is(loaded.feed('x + 1'), 42)
})

test('feedStart pauses and resumes external function calls', (t) => {
const repl = new MontyRepl()

let progress = repl.feedStart('value = get_value()\nvalue')
t.true(progress instanceof MontySnapshot)
t.is((progress as MontySnapshot).functionName, 'get_value')
t.false((progress as MontySnapshot).isOsFunction)

progress = (progress as MontySnapshot).resume({ returnValue: 41 })
t.true(progress instanceof MontyComplete)
t.is((progress as MontyComplete).output, 41)
t.is(repl.feed('value + 1'), 42)
})

test('feedStart pauses and resumes OS calls', (t) => {
const repl = new MontyRepl()

let progress = repl.feedStart('from datetime import datetime\nstamp = datetime.now()\nstamp.year')
t.true(progress instanceof MontySnapshot)
t.is((progress as MontySnapshot).functionName, 'datetime.now')
t.true((progress as MontySnapshot).isOsFunction)

progress = (progress as MontySnapshot).resume({ returnValue: new Date(2032, 4, 6, 7, 8, 9, 10) })
t.true(progress instanceof MontyComplete)
t.is((progress as MontyComplete).output, 2032)
t.is(repl.feed('stamp.month'), 5)
})

test('feedStart restores REPL after print callback error', (t) => {
const repl = new MontyRepl()

const error = t.throws(
() =>
repl.feedStart('value = 41\nprint(value)', {
printCallback: () => {
throw new Error('print failed')
},
}),
{ instanceOf: MontyRuntimeError },
)
t.true(error.message.includes('print failed'))
t.is(repl.feed('value = 41'), null)
t.is(repl.feed('value + 1'), 42)
})
34 changes: 26 additions & 8 deletions crates/monty-js/__test__/start.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,18 +398,36 @@ test('start can reuse monty instance', (t) => {
// OS call handling in start() tests
// =============================================================================

test('os.environ via start() throws RuntimeError', (t) => {
test('os.environ via start() returns OS snapshot', (t) => {
const m = new Monty('import os\nx = os.environ')
const error = t.throws(() => m.start(), { instanceOf: MontyRuntimeError })
t.is(error.exception.typeName, 'RuntimeError')
t.is(error.exception.message, "'os.environ' is not supported in this environment")
const progress = m.start()
t.true(progress instanceof MontySnapshot)
t.is((progress as MontySnapshot).functionName, 'os.environ')
t.true((progress as MontySnapshot).isOsFunction)
t.deepEqual((progress as MontySnapshot).args, [])
t.deepEqual((progress as MontySnapshot).kwargs, {})
})

test('os.getenv via start() throws RuntimeError', (t) => {
test('os.getenv via start() returns OS snapshot', (t) => {
const m = new Monty("import os\nx = os.getenv('HOME')")
const error = t.throws(() => m.start(), { instanceOf: MontyRuntimeError })
t.is(error.exception.typeName, 'RuntimeError')
t.is(error.exception.message, "'os.getenv' is not supported in this environment")
const progress = m.start()
t.true(progress instanceof MontySnapshot)
t.is((progress as MontySnapshot).functionName, 'os.getenv')
t.true((progress as MontySnapshot).isOsFunction)
t.deepEqual((progress as MontySnapshot).args, ['HOME', null])
t.deepEqual((progress as MontySnapshot).kwargs, {})
})

test('datetime.now via start() returns OS snapshot and resumes', (t) => {
const m = new Monty('from datetime import datetime\nnow = datetime.now()\nnow.year')
const progress = m.start()
t.true(progress instanceof MontySnapshot)
t.is((progress as MontySnapshot).functionName, 'datetime.now')
t.true((progress as MontySnapshot).isOsFunction)

const complete = (progress as MontySnapshot).resume({ returnValue: new Date(2031, 6, 8, 9, 10, 11, 12) })
t.true(complete instanceof MontyComplete)
t.is((complete as MontyComplete).output, 2031)
})

// =============================================================================
Expand Down
125 changes: 125 additions & 0 deletions crates/monty-js/src/convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,11 @@ pub fn js_to_monty(value: Unknown<'_>, env: Env) -> Result<MontyObject> {
ValueType::Object => {
let obj: Object = value.coerce_to_object()?;

// Check if it's a Date
if is_js_date(&obj, env)? {
return Ok(MontyObject::DateTime(js_date_to_monty_datetime(obj, &env, None)?));
}

// Check if it's a Buffer (Uint8Array)
if obj.is_buffer()? {
let buffer: BufferSlice = BufferSlice::from_unknown(value)?;
Expand Down Expand Up @@ -509,6 +514,38 @@ pub fn js_to_monty(value: Unknown<'_>, env: Env) -> Result<MontyObject> {
}
}

/// Converts an external function result to `MontyObject`, with OS-call-specific
/// handling for JavaScript `Date` objects.
pub fn js_external_result_to_monty(
function_name: &str,
call_args: &[MontyObject],
value: Unknown<'_>,
env: Env,
) -> Result<MontyObject> {
if value.get_type()? == ValueType::Object {
let obj: Object = value.coerce_to_object()?;
if is_js_date(&obj, env)? {
return match function_name {
"date.today" => Ok(MontyObject::Date(js_date_to_monty_date(obj)?)),
"datetime.now" => Ok(MontyObject::DateTime(js_date_to_monty_datetime(
obj,
&env,
datetime_now_timezone_arg(call_args),
)?)),
_ => Ok(MontyObject::DateTime(js_date_to_monty_datetime(obj, &env, None)?)),
};
}
}
js_to_monty(value, env)
}

fn datetime_now_timezone_arg(args: &[MontyObject]) -> Option<&MontyTimeZone> {
match args.first() {
Some(MontyObject::TimeZone(tz)) => Some(tz),
_ => None,
}
}

/// Checks if a JS object is an instance of Set.
fn is_js_set(obj: &Object, env: Env) -> Result<bool> {
let global = env.get_global()?;
Expand All @@ -523,6 +560,94 @@ fn is_js_map(obj: &Object, env: Env) -> Result<bool> {
obj.instanceof(map_constructor)
}

/// Checks if a JS object is an instance of Date.
fn is_js_date(obj: &Object, env: Env) -> Result<bool> {
let global = env.get_global()?;
let date_constructor: Function<()> = global.get_named_property("Date")?;
obj.instanceof(date_constructor)
}

fn js_date_to_monty_date(date: Object<'_>) -> Result<MontyDate> {
Ok(MontyDate {
year: call_js_date_i32(date, "getFullYear")?,
month: u8::try_from(call_js_date_i32(date, "getMonth")? + 1)
.map_err(|_| Error::from_reason("Date month is out of range"))?,
day: u8::try_from(call_js_date_i32(date, "getDate")?)
.map_err(|_| Error::from_reason("Date day is out of range"))?,
})
}

fn js_date_to_monty_datetime(date: Object<'_>, env: &Env, timezone: Option<&MontyTimeZone>) -> Result<MontyDateTime> {
if let Some(tz) = timezone {
let adjusted = js_date_with_offset(date, env, tz.offset_seconds)?;
return monty_datetime_from_js_date(adjusted, true, Some(tz.offset_seconds), tz.name.clone());
}

monty_datetime_from_js_date(date, false, None, None)
}

fn monty_datetime_from_js_date(
date: Object<'_>,
utc_getters: bool,
offset_seconds: Option<i32>,
timezone_name: Option<String>,
) -> Result<MontyDateTime> {
let method = |local: &'static str, utc: &'static str| if utc_getters { utc } else { local };

Ok(MontyDateTime {
year: call_js_date_i32(date, method("getFullYear", "getUTCFullYear"))?,
month: u8::try_from(call_js_date_i32(date, method("getMonth", "getUTCMonth"))? + 1)
.map_err(|_| Error::from_reason("DateTime month is out of range"))?,
day: u8::try_from(call_js_date_i32(date, method("getDate", "getUTCDate"))?)
.map_err(|_| Error::from_reason("DateTime day is out of range"))?,
hour: u8::try_from(call_js_date_i32(date, method("getHours", "getUTCHours"))?)
.map_err(|_| Error::from_reason("DateTime hour is out of range"))?,
minute: u8::try_from(call_js_date_i32(date, method("getMinutes", "getUTCMinutes"))?)
.map_err(|_| Error::from_reason("DateTime minute is out of range"))?,
second: u8::try_from(call_js_date_i32(date, method("getSeconds", "getUTCSeconds"))?)
.map_err(|_| Error::from_reason("DateTime second is out of range"))?,
microsecond: u32::try_from(call_js_date_i32(date, method("getMilliseconds", "getUTCMilliseconds"))?)
.map_err(|_| Error::from_reason("DateTime millisecond is out of range"))?
* 1000,
offset_seconds,
timezone_name,
})
}

fn js_date_with_offset<'e>(date: Object<'_>, env: &'e Env, offset_seconds: i32) -> Result<Object<'e>> {
let time_ms = call_js_date_f64(date, "getTime")?;
let adjusted_ms = time_ms + f64::from(offset_seconds) * 1000.0;
let global = env.get_global()?;
let date_constructor: Function<f64> = global.get_named_property("Date")?;
date_constructor.new_instance(adjusted_ms)?.coerce_to_object()
}

fn call_js_date_i32(date: Object<'_>, method_name: &str) -> Result<i32> {
let value = call_js_date_f64(date, method_name)?;
if value.fract() != 0.0 || value < f64::from(i32::MIN) || value > f64::from(i32::MAX) {
return Err(Error::from_reason(format!(
"Date method {method_name} returned an out-of-range integer"
)));
}
#[expect(
clippy::cast_possible_truncation,
reason = "JavaScript Date component methods return small integers"
)]
Ok(value as i32)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
}

fn call_js_date_f64(date: Object<'_>, method_name: &str) -> Result<f64> {
let method: Function<()> = date.get_named_property(method_name)?;
let value: Unknown = method.apply(date, ())?;
let value = value.coerce_to_number()?.get_double()?;
if !value.is_finite() {
return Err(Error::from_reason(format!(
"Date method {method_name} returned an invalid number"
)));
}
Ok(value)
}

/// Converts a JS Map to `MontyObject::Dict`.
fn js_map_to_monty(map: Object, env: Env) -> Result<MontyObject> {
// Get the entries iterator
Expand Down
Loading
Loading