diff --git a/docs/concepts/types.md b/docs/concepts/types.md index d1d351a..fd72764 100644 --- a/docs/concepts/types.md +++ b/docs/concepts/types.md @@ -108,6 +108,36 @@ with Runtime() as runtime: All datetime values are normalized to UTC during conversion. If you pass a naive (timezone-unaware) Python datetime, it will be treated as UTC. +### Temporal + +JavaScript's [Temporal][temporal] API (available natively in the bundled V8 engine) converts to Python's native date/time types, and Python `date`, `time`, and `timedelta` values convert to Temporal instances: + +| JavaScript | Python | Notes | +|---|---|---| +| `Temporal.Instant` | aware `datetime` (UTC) | sub-microsecond precision truncated | +| `Temporal.ZonedDateTime` | aware `datetime` (`ZoneInfo` or fixed offset) | | +| `Temporal.PlainDate` | `date` | non-ISO calendars normalized to ISO | +| `Temporal.PlainTime` | `time` | | +| `Temporal.PlainDateTime` | naive `datetime` | | +| `Temporal.Duration` | `timedelta` | | + +```python +from datetime import date, timedelta + +with Runtime() as runtime: + # Temporal → Python + d = runtime.eval("Temporal.PlainDate.from('2026-10-03')") + assert d == date(2026, 10, 3) + + # Python → Temporal + check = runtime.eval("(d) => d instanceof Temporal.PlainDate") + assert check(date(2026, 10, 3)) is True +``` + +Two asymmetries to be aware of: Python `datetime` still converts to a JavaScript `Date` (not Temporal) for backwards compatibility, and `Temporal.Duration` values with nonzero `years`/`months`/`weeks` are calendar-relative with no fixed length, so they are not converted to `timedelta`. + +[temporal]: https://tc39.es/proposal-temporal/docs/ + ## Binary Data Binary data is represented as `bytes` in Python and `Uint8Array` in JavaScript: diff --git a/src/runtime/conversion.rs b/src/runtime/conversion.rs index e0930c7..998294a 100644 --- a/src/runtime/conversion.rs +++ b/src/runtime/conversion.rs @@ -8,14 +8,15 @@ use pyo3::conversion::IntoPyObject; use pyo3::exceptions::PyRuntimeError; use pyo3::prelude::*; use pyo3::types::{ - PyBool, PyByteArray, PyBytes, PyDateTime, PyDict, PyFloat, PyFrozenSet, PyFrozenSetMethods, - PyInt, PyList, PyMemoryView, PySet, PySetMethods, PyString, + PyBool, PyByteArray, PyBytes, PyDate, PyDateTime, PyDelta, PyDict, PyFloat, PyFrozenSet, + PyFrozenSetMethods, PyInt, PyList, PyMemoryView, PySet, PySetMethods, PyString, PyTime, }; use std::collections::HashSet; const TYPE_TAG: &str = "__jsrun_type"; const UNDEFINED_TYPE: &str = "Undefined"; const DATE_TYPE: &str = "Date"; +const TEMPORAL_TYPE: &str = "Temporal"; const DATE_EPOCH_KEY: &str = "epoch_ms"; const SET_TYPE: &str = "Set"; const SET_VALUES_KEY: &str = "values"; @@ -27,6 +28,219 @@ const BIGINT_VALUE_KEY: &str = "value"; /// This is the new primary conversion function that supports native JavaScript values /// including NaN and ±Infinity without sentinel strings. /// +/// Build a tagged Temporal payload, materialized as a real Temporal instance +/// on the JavaScript side by the bridge helpers. +fn temporal_tagged(kind: &str, fields: [(&str, JSValue); N]) -> JSValue { + let mut map = IndexMap::new(); + map.insert(TYPE_TAG.to_string(), JSValue::String(TEMPORAL_TYPE.into())); + map.insert("kind".to_string(), JSValue::String(kind.into())); + for (key, value) in fields { + map.insert(key.to_string(), value); + } + JSValue::Object(map) +} + +fn temporal_field_i64(map: &IndexMap, key: &str) -> PyResult { + match map.get(key) { + Some(JSValue::Int(v)) => Ok(*v), + Some(JSValue::Float(f)) if f.is_finite() && f.fract() == 0.0 => Ok(*f as i64), + _ => Err(PyRuntimeError::new_err(format!( + "Invalid Temporal payload: missing or non-integer '{key}'" + ))), + } +} + +fn temporal_field_i128(map: &IndexMap, key: &str) -> PyResult { + let text = match map.get(key) { + Some(JSValue::String(s)) => s.clone(), + Some(JSValue::Int(v)) => return Ok(*v as i128), + Some(JSValue::BigInt(b)) => b.to_string(), + _ => { + return Err(PyRuntimeError::new_err(format!( + "Invalid Temporal payload: missing '{key}'" + ))) + } + }; + text.parse::().map_err(|_| { + PyRuntimeError::new_err(format!("Invalid Temporal payload: non-integer '{key}'")) + }) +} + +fn temporal_field_str<'a>(map: &'a IndexMap, key: &str) -> PyResult<&'a str> { + match map.get(key) { + Some(JSValue::String(s)) => Ok(s), + _ => Err(PyRuntimeError::new_err(format!( + "Invalid Temporal payload: missing '{key}'" + ))), + } +} + +/// Resolve a Temporal time zone identifier to a Python tzinfo: "UTC", +/// fixed offsets like "+05:30", or IANA names via zoneinfo. +fn resolve_timezone<'py>(py: Python<'py>, tz: &str) -> PyResult> { + let datetime_mod = py.import("datetime")?; + let timezone_cls = datetime_mod.getattr("timezone")?; + if tz.eq_ignore_ascii_case("UTC") { + return timezone_cls.getattr("utc"); + } + if let Some(rest) = tz.strip_prefix('+').or_else(|| tz.strip_prefix('-')) { + let sign: i64 = if tz.starts_with('-') { -1 } else { 1 }; + let mut parts = rest.split(':'); + let parse = |p: Option<&str>| -> PyResult { + match p { + None => Ok(0), + Some(s) => s.parse::().map_err(|_| { + PyRuntimeError::new_err(format!("Invalid time zone offset '{tz}'")) + }), + } + }; + let hours = parse(parts.next())?; + let minutes = parse(parts.next())?; + let seconds = parse(parts.next())?; + let offset = sign * (hours * 3600 + minutes * 60 + seconds); + let kwargs = PyDict::new(py); + kwargs.set_item("seconds", offset)?; + let delta = datetime_mod.getattr("timedelta")?.call((), Some(&kwargs))?; + return timezone_cls.call1((delta,)); + } + let zoneinfo_cls = py.import("zoneinfo")?.getattr("ZoneInfo")?; + zoneinfo_cls.call1((tz,)).map_err(|err| { + PyRuntimeError::new_err(format!( + "Unknown time zone '{tz}' from Temporal value: {err}" + )) + }) +} + +/// Build an aware datetime from epoch nanoseconds (sub-microsecond precision +/// is truncated), optionally converted into the given time zone. +fn epoch_ns_to_datetime(py: Python<'_>, epoch_ns: i128, tz: Option<&str>) -> PyResult> { + let datetime_mod = py.import("datetime")?; + let utc = datetime_mod.getattr("timezone")?.getattr("utc")?; + let secs = epoch_ns.div_euclid(1_000_000_000); + + // Determine the zone's UTC offset at this instant, then build the local + // wall-clock fields with integer math. Constructing the intermediate UTC + // datetime would reject instants whose *local* representation is valid + // (e.g. 0001-01-01T00:00:00+01:00 is fine, but its UTC form is year 0). + let mut offset_delta = None; + let (tzinfo, offset_ns) = match tz { + None => (utc.clone(), 0i128), + Some(tz) => { + let tzinfo = resolve_timezone(py, tz)?; + // Probe the offset at a clamped, always-representable instant: + // exact whenever the instant itself is in datetime range, and a + // best-effort approximation at the extreme year boundaries. + let probe_secs: i64 = secs.clamp(-62_135_000_000, 253_300_000_000) as i64; + let probe = datetime_mod + .getattr("datetime")? + .call_method1("fromtimestamp", (probe_secs, &utc))? + .call_method1("astimezone", (&tzinfo,))?; + let offset = probe.call_method0("utcoffset")?; + let days: i128 = offset.getattr("days")?.extract()?; + let seconds: i128 = offset.getattr("seconds")?.extract()?; + let microseconds: i128 = offset.getattr("microseconds")?.extract()?; + let offset_ns = ((days * 86_400 + seconds) * 1_000_000 + microseconds) * 1000; + offset_delta = Some(offset); + (tzinfo, offset_ns) + } + }; + + let local_ns = epoch_ns + offset_ns; + let days = local_ns.div_euclid(86_400_000_000_000); + let rem_ns = local_ns.rem_euclid(86_400_000_000_000); + // 719_163 is the proleptic-Gregorian ordinal of 1970-01-01. + let ordinal: i64 = (days + 719_163) + .try_into() + .map_err(|_| PyRuntimeError::new_err("Temporal value out of range for Python datetime"))?; + let py_date = datetime_mod + .getattr("date")? + .call_method1("fromordinal", (ordinal,)) + .map_err(|_| PyRuntimeError::new_err("Temporal value out of range for Python datetime"))?; + let micros_of_day = rem_ns / 1000; + let py_time = datetime_mod.getattr("time")?.call1(( + (micros_of_day / 3_600_000_000) as i64, + (micros_of_day / 60_000_000 % 60) as i64, + (micros_of_day / 1_000_000 % 60) as i64, + (micros_of_day % 1_000_000) as i64, + ))?; + let mut dt = datetime_mod + .getattr("datetime")? + .call_method1("combine", (py_date, py_time, tzinfo))?; + // Ambiguous wall times (DST fall-back) default to fold=0; pick fold=1 + // when that occurrence is the one matching the instant's actual offset. + if let Some(offset_delta) = offset_delta { + if !dt.call_method0("utcoffset")?.eq(&offset_delta)? { + let kwargs = PyDict::new(py); + kwargs.set_item("fold", 1)?; + dt = dt.call_method("replace", (), Some(&kwargs))?; + } + } + Ok(dt.unbind()) +} + +/// Convert a tagged Temporal payload into the matching Python type. +/// Returns Ok(None) for unknown kinds so the generic dict fallback applies. +fn temporal_to_python( + py: Python<'_>, + map: &IndexMap, +) -> PyResult>> { + let kind = match map.get("kind") { + Some(JSValue::String(kind)) => kind.as_str(), + _ => return Ok(None), + }; + let datetime_mod = py.import("datetime")?; + let converted = match kind { + "Instant" => epoch_ns_to_datetime(py, temporal_field_i128(map, "epoch_ns")?, None)?, + "ZonedDateTime" => epoch_ns_to_datetime( + py, + temporal_field_i128(map, "epoch_ns")?, + Some(temporal_field_str(map, "time_zone")?), + )?, + "PlainDate" => datetime_mod + .getattr("date")? + .call1(( + temporal_field_i64(map, "year")?, + temporal_field_i64(map, "month")?, + temporal_field_i64(map, "day")?, + ))? + .unbind(), + "PlainTime" => datetime_mod + .getattr("time")? + .call1(( + temporal_field_i64(map, "hour")?, + temporal_field_i64(map, "minute")?, + temporal_field_i64(map, "second")?, + temporal_field_i64(map, "nanosecond")? / 1000, + ))? + .unbind(), + "PlainDateTime" => datetime_mod + .getattr("datetime")? + .call1(( + temporal_field_i64(map, "year")?, + temporal_field_i64(map, "month")?, + temporal_field_i64(map, "day")?, + temporal_field_i64(map, "hour")?, + temporal_field_i64(map, "minute")?, + temporal_field_i64(map, "second")?, + temporal_field_i64(map, "nanosecond")? / 1000, + ))? + .unbind(), + "Duration" => { + // i128 microseconds convert to an arbitrary-precision Python int; + // timedelta's own constructor enforces its range. + let micros: i128 = temporal_field_i128(map, "ns")? / 1000; + let kwargs = PyDict::new(py); + kwargs.set_item("microseconds", micros)?; + datetime_mod + .getattr("timedelta")? + .call((), Some(&kwargs))? + .unbind() + } + _ => return Ok(None), + }; + Ok(Some(converted)) +} + /// For Function variants, a RuntimeHandle must be provided to create JsFunction proxies. pub(crate) fn js_value_to_python( py: Python<'_>, @@ -106,6 +320,11 @@ pub(crate) fn js_value_to_python( return Ok(obj.into_any().unbind()); } } + TEMPORAL_TYPE => { + if let Some(converted) = temporal_to_python(py, map)? { + return Ok(converted); + } + } _ => {} } } @@ -334,6 +553,47 @@ fn python_to_js_value_internal( )); } Ok(JSValue::Date(epoch_ms.round() as i64)) + } else if let Ok(py_date) = obj.cast::() { + // Pure date (datetime is matched above); becomes Temporal.PlainDate. + add_bytes(16, tracker)?; + let field = |name: &str| -> PyResult { py_date.getattr(name)?.extract::() }; + Ok(temporal_tagged( + "PlainDate", + [ + ("year", JSValue::Int(field("year")?)), + ("month", JSValue::Int(field("month")?)), + ("day", JSValue::Int(field("day")?)), + ], + )) + } else if let Ok(py_time) = obj.cast::() { + // Becomes Temporal.PlainTime; aware times have no Temporal analogue. + if !py_time.getattr("tzinfo")?.is_none() { + return Err(PyRuntimeError::new_err( + "datetime.time with tzinfo is not supported; use a full datetime instead", + )); + } + add_bytes(16, tracker)?; + let field = |name: &str| -> PyResult { py_time.getattr(name)?.extract::() }; + Ok(temporal_tagged( + "PlainTime", + [ + ("hour", JSValue::Int(field("hour")?)), + ("minute", JSValue::Int(field("minute")?)), + ("second", JSValue::Int(field("second")?)), + ("nanosecond", JSValue::Int(field("microsecond")? * 1000)), + ], + )) + } else if let Ok(py_delta) = obj.cast::() { + // Becomes Temporal.Duration (exact: timedelta is microsecond-based). + add_bytes(24, tracker)?; + let field = |name: &str| -> PyResult { py_delta.getattr(name)?.extract::() }; + let total_ns = ((field("days")? * 86_400 + field("seconds")?) * 1_000_000 + + field("microseconds")?) + * 1000; + Ok(temporal_tagged( + "Duration", + [("ns", JSValue::String(total_ns.to_string()))], + )) } else if let Ok(b) = obj.extract::() { add_bytes(1, tracker)?; Ok(JSValue::Bool(b)) diff --git a/src/runtime/ops.rs b/src/runtime/ops.rs index 4400ae4..fbfa5b0 100644 --- a/src/runtime/ops.rs +++ b/src/runtime/ops.rs @@ -340,6 +340,110 @@ pub fn python_extension(registry: PythonOpRegistry) -> Extension { }; } + // Temporal values cross the boundary as tagged plain objects (their data + // lives in internal slots, so generic object conversion yields {}). + // Calendar-dependent fields are normalized to ISO; Durations with + // years/months/weeks are calendar-relative and stay unconverted. + function temporalPrepare(value) { + if (typeof Temporal === "undefined" || value === null || typeof value !== "object") { + return undefined; + } + try { + if (value instanceof Temporal.Instant) { + return { __jsrun_type: "Temporal", kind: "Instant", epoch_ns: value.epochNanoseconds.toString() }; + } + if (value instanceof Temporal.ZonedDateTime) { + return { + __jsrun_type: "Temporal", + kind: "ZonedDateTime", + epoch_ns: value.epochNanoseconds.toString(), + time_zone: value.timeZoneId, + }; + } + if (value instanceof Temporal.PlainDate) { + const d = value.withCalendar("iso8601"); + return { __jsrun_type: "Temporal", kind: "PlainDate", year: d.year, month: d.month, day: d.day }; + } + if (value instanceof Temporal.PlainDateTime) { + const d = value.withCalendar("iso8601"); + return { + __jsrun_type: "Temporal", + kind: "PlainDateTime", + year: d.year, month: d.month, day: d.day, + hour: d.hour, minute: d.minute, second: d.second, + nanosecond: d.millisecond * 1e6 + d.microsecond * 1e3 + d.nanosecond, + }; + } + if (value instanceof Temporal.PlainTime) { + return { + __jsrun_type: "Temporal", + kind: "PlainTime", + hour: value.hour, minute: value.minute, second: value.second, + nanosecond: value.millisecond * 1e6 + value.microsecond * 1e3 + value.nanosecond, + }; + } + if (value instanceof Temporal.Duration) { + if (value.years !== 0 || value.months !== 0 || value.weeks !== 0) { + return undefined; + } + const seconds = + ((BigInt(value.days) * 24n + BigInt(value.hours)) * 60n + BigInt(value.minutes)) * 60n + + BigInt(value.seconds); + const total = + seconds * 1000000000n + + BigInt(value.milliseconds) * 1000000n + + BigInt(value.microseconds) * 1000n + + BigInt(value.nanoseconds); + return { __jsrun_type: "Temporal", kind: "Duration", ns: total.toString() }; + } + } catch (_) { + // fall through to generic conversion + } + return undefined; + } + + function temporalRevive(entry) { + if (typeof Temporal === "undefined") { + return entry; + } + switch (entry.kind) { + case "Instant": + return Temporal.Instant.fromEpochNanoseconds(BigInt(entry.epoch_ns)); + case "ZonedDateTime": + return new Temporal.ZonedDateTime(BigInt(entry.epoch_ns), entry.time_zone); + case "PlainDate": + return new Temporal.PlainDate(entry.year, entry.month, entry.day); + case "PlainTime": { + const ns = Number(entry.nanosecond); + return new Temporal.PlainTime( + entry.hour, entry.minute, entry.second, + Math.floor(ns / 1e6), Math.floor(ns / 1e3) % 1000, ns % 1000, + ); + } + case "PlainDateTime": { + const ns = Number(entry.nanosecond); + return new Temporal.PlainDateTime( + entry.year, entry.month, entry.day, + entry.hour, entry.minute, entry.second, + Math.floor(ns / 1e6), Math.floor(ns / 1e3) % 1000, ns % 1000, + ); + } + case "Duration": { + const total = BigInt(entry.ns); + const sign = total < 0n ? -1n : 1n; + const abs = total * sign; + return new Temporal.Duration( + 0, 0, 0, 0, 0, 0, + Number((abs / 1000000000n) * sign), 0, 0, + Number((abs % 1000000000n) * sign), + ); + } + } + return entry; + } + globalThis.__jsrun_temporal_prepare = temporalPrepare; + globalThis.__jsrun_temporal_revive = temporalRevive; + function prepare(value) { if (value === undefined || value === null) { return value; @@ -353,6 +457,10 @@ pub fn python_extension(registry: PythonOpRegistry) -> Extension { if (value instanceof Date) { return { __jsrun_type: "Date", epoch_ms: value.valueOf() }; } + const temporal = temporalPrepare(value); + if (temporal !== undefined) { + return temporal; + } if (value instanceof Set) { return { __jsrun_type: "Set", @@ -407,6 +515,8 @@ pub fn python_extension(registry: PythonOpRegistry) -> Extension { } case "BigInt": return BigInt(value.value); + case "Temporal": + return temporalRevive(value); case "PyStream": if (typeof globalThis.__jsrun_from_py_stream === "function") { return globalThis.__jsrun_from_py_stream(value.id); diff --git a/src/runtime/runner.rs b/src/runtime/runner.rs index 0dcc92d..c2573c9 100644 --- a/src/runtime/runner.rs +++ b/src/runtime/runner.rs @@ -2223,6 +2223,29 @@ impl RuntimeCoreState { value.instance_of(scope, ctor.into()).unwrap_or_default() } + /// Convert a Temporal instance into a tagged plain object via the bridge + /// helper. Returns None for non-Temporal values (including when the + /// helper is unavailable), letting generic conversion proceed. + fn temporal_prepare<'s>( + scope: &mut v8::PinScope<'s, '_>, + value: v8::Local<'s, v8::Value>, + ) -> Option> { + if !value.is_object() || value.is_function() { + return None; + } + let context = scope.get_current_context(); + let global = context.global(scope); + let key = v8::String::new(scope, "__jsrun_temporal_prepare")?; + let helper_value = global.get(scope, key.into())?; + let helper = v8::Local::::try_from(helper_value).ok()?; + let result = helper.call(scope, global.into(), &[value])?; + if result.is_object() { + Some(result) + } else { + None + } + } + fn store_pending_call( &self, promise: v8::Global, @@ -2906,6 +2929,27 @@ impl RuntimeCoreState { RuntimeError::internal(format!("Failed to set property '{key}'")) })?; } + // Tagged Temporal payloads (from Python date/time/timedelta) + // are upgraded to real Temporal instances by the bridge helper. + if matches!(map.get("__jsrun_type"), Some(JSValue::String(tag)) if tag == "Temporal") + { + let context = scope.get_current_context(); + let global = context.global(scope); + let helper_key = v8::String::new(scope, "__jsrun_temporal_revive") + .ok_or_else(|| RuntimeError::internal("Failed to allocate helper key"))?; + let helper_value = global.get(scope, helper_key.into()).ok_or_else(|| { + RuntimeError::internal("Missing __jsrun_temporal_revive helper") + })?; + let helper_fn = + v8::Local::::try_from(helper_value).map_err(|_| { + RuntimeError::internal("__jsrun_temporal_revive is not callable") + })?; + return helper_fn + .call(scope, global.into(), &[object.into()]) + .ok_or_else(|| { + RuntimeError::internal("__jsrun_temporal_revive invocation failed") + }); + } Ok(object.into()) } JSValue::Date(epoch_ms) => { @@ -3151,6 +3195,19 @@ impl RuntimeCoreState { } tracker.add_bytes(16)?; Ok(JSValue::Date(epoch_ms.round() as i64)) + } else if let Some(prepared) = Self::temporal_prepare(scope, value) { + // Temporal instance converted by the bridge helper into a tagged + // plain object; recurse to serialize its fields. + Self::value_to_js_value_internal( + fn_registry, + next_fn_id, + scope, + prepared, + seen, + tracker, + None, + stream_registry, + ) } else if value.is_object() && Self::is_readable_stream(scope, value) { let stream_id = stream_registry.register_stream(scope, value); tracker.add_bytes(size_of::())?; diff --git a/tests/test_temporal.py b/tests/test_temporal.py new file mode 100644 index 0000000..f72d3b3 --- /dev/null +++ b/tests/test_temporal.py @@ -0,0 +1,204 @@ +""" +Temporal <-> Python conversion tests. + +JavaScript Temporal values cross into Python as datetime types and +Python date/time/timedelta values cross into JavaScript as Temporal +instances, via the tagged-object bridge in `src/runtime/ops.rs` and the +conversions in `src/runtime/conversion.rs`. +""" + +from datetime import date, datetime, time, timedelta, timezone +from zoneinfo import ZoneInfo + +import pytest +from jsrun import Runtime + + +class TestTemporalToPython: + def test_instant_to_aware_datetime(self): + with Runtime() as rt: + result = rt.eval("Temporal.Instant.from('2026-10-03T12:30:45.123456789Z')") + assert isinstance(result, datetime) + assert result.tzinfo is not None + assert result.utcoffset() == timedelta(0) + # Sub-microsecond precision is truncated. + assert result == datetime( + 2026, 10, 3, 12, 30, 45, 123456, tzinfo=timezone.utc + ) + + def test_negative_epoch_instant(self): + with Runtime() as rt: + result = rt.eval("Temporal.Instant.from('1960-01-01T00:00:00.5Z')") + assert result == datetime(1960, 1, 1, 0, 0, 0, 500000, tzinfo=timezone.utc) + + def test_zoned_datetime_iana(self): + with Runtime() as rt: + result = rt.eval( + "Temporal.ZonedDateTime.from('2026-07-04T09:00:00[America/New_York]')" + ) + assert isinstance(result, datetime) + assert result.tzinfo == ZoneInfo("America/New_York") + assert result.hour == 9 + assert result.utcoffset() == timedelta(hours=-4) # EDT + + def test_zoned_datetime_offset_zone(self): + with Runtime() as rt: + result = rt.eval( + "Temporal.ZonedDateTime.from('2026-10-03T10:00:00+05:30[+05:30]')" + ) + assert result.utcoffset() == timedelta(hours=5, minutes=30) + assert result.hour == 10 + + def test_plain_date(self): + with Runtime() as rt: + assert rt.eval("Temporal.PlainDate.from('2026-10-03')") == date(2026, 10, 3) + + def test_plain_date_non_iso_calendar_normalized(self): + with Runtime() as rt: + result = rt.eval( + "Temporal.PlainDate.from('2026-10-03').withCalendar('hebrew')" + ) + assert result == date(2026, 10, 3) + + def test_plain_time(self): + with Runtime() as rt: + result = rt.eval("Temporal.PlainTime.from('14:15:16.123456789')") + assert result == time(14, 15, 16, 123456) + + def test_plain_datetime_naive(self): + with Runtime() as rt: + result = rt.eval("Temporal.PlainDateTime.from('2026-10-03T14:15:16.5')") + # PlainDateTime is zoneless wall-clock time; a naive datetime is + # exactly the expected value. + assert result == datetime(2026, 10, 3, 14, 15, 16, 500000) # noqa: DTZ001 + assert result.tzinfo is None + + def test_duration(self): + with Runtime() as rt: + assert rt.eval("Temporal.Duration.from('PT1H30M')") == timedelta( + hours=1, minutes=30 + ) + assert rt.eval("Temporal.Duration.from('-PT2.5S')") == timedelta( + seconds=-2.5 + ) + assert rt.eval("Temporal.Duration.from({ days: 3 })") == timedelta(days=3) + + def test_large_duration(self): + with Runtime() as rt: + rt.bind_function("echo", lambda v: v) + big = timedelta(days=106751992) + assert rt.eval( + f"echo(Temporal.Duration.from({{ days: {big.days} }})) !== null" + ) + check = rt.eval("(d) => d") + assert check(big) == big + + def test_zoned_datetime_at_year_boundaries(self): + with Runtime() as rt: + low = rt.eval( + "Temporal.ZonedDateTime.from('0001-01-01T00:00:00+01:00[+01:00]')" + ) + assert low.year == 1 + assert low.utcoffset() == timedelta(hours=1) + high = rt.eval( + "Temporal.ZonedDateTime.from('9999-12-31T23:59:59-05:00[-05:00]')" + ) + assert high.year == 9999 + assert high.utcoffset() == timedelta(hours=-5) + + def test_zoned_datetime_dst_fold(self): + with Runtime() as rt: + # 2026-11-01 in America/New_York: 01:30 EDT and 01:30 EST both + # exist; the two instants must stay one hour apart after + # conversion, with the second occurrence carrying fold=1. + first = rt.eval( + "Temporal.Instant.from('2026-11-01T05:30:00Z')" + ".toZonedDateTimeISO('America/New_York')" + ) + second = rt.eval( + "Temporal.Instant.from('2026-11-01T06:30:00Z')" + ".toZonedDateTimeISO('America/New_York')" + ) + assert first.utcoffset() == timedelta(hours=-4) # EDT + assert second.utcoffset() == timedelta(hours=-5) # EST + assert second.fold == 1 + # Same-zone aware subtraction compares wall clocks (ignoring + # fold), so compare the actual instants via timestamps. + assert second.timestamp() - first.timestamp() == 3600 + + def test_calendar_relative_duration_not_converted(self): + with Runtime() as rt: + # years/months/weeks have no fixed length; stays unconverted. + result = rt.eval("Temporal.Duration.from({ months: 2 })") + assert not isinstance(result, timedelta) + + def test_nested_in_containers(self): + with Runtime() as rt: + result = rt.eval( + "({ when: Temporal.PlainDate.from('2026-01-02')," + " spans: [Temporal.Duration.from('PT5S')] })" + ) + assert result == {"when": date(2026, 1, 2), "spans": [timedelta(seconds=5)]} + + def test_ops_path_argument(self): + with Runtime() as rt: + seen = {} + rt.bind_function("capture", lambda v: seen.update(value=v)) + rt.eval("capture(Temporal.PlainDate.from('2026-10-03'))") + assert seen["value"] == date(2026, 10, 3) + + +class TestPythonToTemporal: + def test_date_to_plain_date(self): + with Runtime() as rt: + check = rt.eval( + "(d) => d instanceof Temporal.PlainDate ? d.toString() : 'nope'" + ) + assert check(date(2026, 10, 3)) == "2026-10-03" + + def test_time_to_plain_time(self): + with Runtime() as rt: + check = rt.eval( + "(t) => t instanceof Temporal.PlainTime ? t.toString() : 'nope'" + ) + assert check(time(14, 15, 16, 123456)) == "14:15:16.123456" + + def test_timedelta_to_duration(self): + with Runtime() as rt: + check = rt.eval( + "(d) => d instanceof Temporal.Duration ? d.total('milliseconds') : 'nope'" + ) + assert check(timedelta(hours=1, minutes=30)) == 5_400_000 + assert check(timedelta(seconds=-2.5)) == -2_500 + + def test_datetime_still_maps_to_date(self): + # Back-compat: datetime keeps converting to a JS Date. + with Runtime() as rt: + check = rt.eval("(d) => d instanceof Date") + assert check(datetime(2026, 10, 3, tzinfo=timezone.utc)) is True + + def test_aware_time_rejected(self): + with Runtime() as rt: + identity = rt.eval("(t) => t") + with pytest.raises(Exception, match="tzinfo"): + identity(time(12, 0, tzinfo=timezone.utc)) + + def test_ops_path_return_value(self): + with Runtime() as rt: + rt.bind_function("today", lambda: date(2026, 10, 3)) + assert rt.eval("today() instanceof Temporal.PlainDate") is True + assert rt.eval("today().toString()") == "2026-10-03" + + def test_roundtrip_through_python(self): + with Runtime() as rt: + rt.bind_function("echo", lambda v: v) + assert ( + rt.eval( + "echo(Temporal.PlainDate.from('2026-10-03'))" + ".equals(Temporal.PlainDate.from('2026-10-03'))" + ) + is True + ) + assert ( + rt.eval("echo(Temporal.Duration.from('PT90S')).total('seconds')") == 90 + )