From ecca4020e7c9af79728b5cecf8f67c2f60104784 Mon Sep 17 00:00:00 2001 From: Xin Date: Sat, 3 Oct 2026 16:31:04 +0100 Subject: [PATCH 1/3] feat: convert Temporal values to and from Python datetime types Temporal objects previously crossed the boundary as empty dicts (their data lives in internal slots invisible to generic object conversion). They now convert via tagged payloads, following the existing Date/Set bridge pattern across all four boundary paths (eval results, function call args, op args, op return values): Temporal.Instant <-> aware datetime (UTC; sub-us truncated) Temporal.ZonedDateTime <-> aware datetime (ZoneInfo / fixed offset) Temporal.PlainDate <-> date (non-ISO calendars normalized) Temporal.PlainTime <-> time Temporal.PlainDateTime <-> naive datetime Temporal.Duration <-> timedelta Python date, time, and timedelta previously raised 'Unsupported Python type'; they now convert to the matching Temporal instances. datetime keeps converting to a JS Date for backwards compatibility. Durations with nonzero years/months/weeks are calendar-relative and stay unconverted. 21 new tests; docs in concepts/types.md. --- docs/concepts/types.md | 30 +++++ src/runtime/conversion.rs | 223 +++++++++++++++++++++++++++++++++++++- src/runtime/ops.rs | 110 +++++++++++++++++++ src/runtime/runner.rs | 57 ++++++++++ tests/test_temporal.py | 161 +++++++++++++++++++++++++++ 5 files changed, 579 insertions(+), 2 deletions(-) create mode 100644 tests/test_temporal.py 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..3b11cfc 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,178 @@ 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 secs: i64 = epoch_ns + .div_euclid(1_000_000_000) + .try_into() + .map_err(|_| PyRuntimeError::new_err("Temporal value out of range for Python datetime"))?; + let micros = (epoch_ns.rem_euclid(1_000_000_000) / 1000) as i64; + let utc = datetime_mod.getattr("timezone")?.getattr("utc")?; + let dt = datetime_mod + .getattr("datetime")? + .call_method1("fromtimestamp", (secs, utc))?; + let kwargs = PyDict::new(py); + kwargs.set_item("microseconds", micros)?; + let delta = datetime_mod.getattr("timedelta")?.call((), Some(&kwargs))?; + let mut dt = dt.add(delta)?; + if let Some(tz) = tz { + let tzinfo = resolve_timezone(py, tz)?; + dt = dt.call_method1("astimezone", (tzinfo,))?; + } + 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" => { + let micros: i64 = (temporal_field_i128(map, "ns")? / 1000) + .try_into() + .map_err(|_| { + PyRuntimeError::new_err("Temporal.Duration out of range for Python timedelta") + })?; + 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 +279,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 +512,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..96ae0a4 --- /dev/null +++ b/tests/test_temporal.py @@ -0,0 +1,161 @@ +""" +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_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 + ) From 44ca27ef0a870e171aa2bfede26805a8e1fa80ec Mon Sep 17 00:00:00 2001 From: Xin Date: Wed, 7 Oct 2026 21:20:59 +0100 Subject: [PATCH 2/3] fix: handle large durations and year-boundary zoned datetimes Review findings on the Temporal conversion: - Duration: drop the i64 narrowing of total microseconds; i128 converts to an arbitrary-precision Python int and timedelta's constructor enforces its own range, so timedelta.max-scale durations round-trip. - ZonedDateTime/Instant: build the local wall-clock fields with integer math (offset probe + date.fromordinal) instead of materializing the intermediate UTC datetime, which rejected instants whose local form is valid but whose UTC form falls outside years 1-9999. Ambiguous DST fall-back wall times get fold=1 when that occurrence matches the instant's actual offset. --- src/runtime/conversion.rs | 77 ++++++++++++++++++++++++++++++--------- tests/test_temporal.py | 37 +++++++++++++++++++ 2 files changed, 96 insertions(+), 18 deletions(-) diff --git a/src/runtime/conversion.rs b/src/runtime/conversion.rs index 3b11cfc..998294a 100644 --- a/src/runtime/conversion.rs +++ b/src/runtime/conversion.rs @@ -115,22 +115,65 @@ fn resolve_timezone<'py>(py: Python<'py>, tz: &str) -> PyResult, epoch_ns: i128, tz: Option<&str>) -> PyResult> { let datetime_mod = py.import("datetime")?; - let secs: i64 = epoch_ns - .div_euclid(1_000_000_000) + 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 micros = (epoch_ns.rem_euclid(1_000_000_000) / 1000) as i64; - let utc = datetime_mod.getattr("timezone")?.getattr("utc")?; - let dt = datetime_mod + 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("fromtimestamp", (secs, utc))?; - let kwargs = PyDict::new(py); - kwargs.set_item("microseconds", micros)?; - let delta = datetime_mod.getattr("timedelta")?.call((), Some(&kwargs))?; - let mut dt = dt.add(delta)?; - if let Some(tz) = tz { - let tzinfo = resolve_timezone(py, tz)?; - dt = dt.call_method1("astimezone", (tzinfo,))?; + .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()) } @@ -183,11 +226,9 @@ fn temporal_to_python( ))? .unbind(), "Duration" => { - let micros: i64 = (temporal_field_i128(map, "ns")? / 1000) - .try_into() - .map_err(|_| { - PyRuntimeError::new_err("Temporal.Duration out of range for Python timedelta") - })?; + // 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 diff --git a/tests/test_temporal.py b/tests/test_temporal.py index 96ae0a4..2ccddd9 100644 --- a/tests/test_temporal.py +++ b/tests/test_temporal.py @@ -83,6 +83,43 @@ def test_duration(self): ) 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. From 57274f0ee15430d62642e2f5efda569e1696ca69 Mon Sep 17 00:00:00 2001 From: Xin Date: Wed, 7 Oct 2026 21:21:32 +0100 Subject: [PATCH 3/3] style: format test_temporal.py --- tests/test_temporal.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/test_temporal.py b/tests/test_temporal.py index 2ccddd9..f72d3b3 100644 --- a/tests/test_temporal.py +++ b/tests/test_temporal.py @@ -87,16 +87,22 @@ 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") + 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]')") + 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]')") + 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)