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
30 changes: 30 additions & 0 deletions docs/concepts/types.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
264 changes: 262 additions & 2 deletions src/runtime/conversion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<const N: usize>(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<String, JSValue>, key: &str) -> PyResult<i64> {
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<String, JSValue>, key: &str) -> PyResult<i128> {
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::<i128>().map_err(|_| {
PyRuntimeError::new_err(format!("Invalid Temporal payload: non-integer '{key}'"))
})
}

fn temporal_field_str<'a>(map: &'a IndexMap<String, JSValue>, 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<pyo3::Bound<'py, PyAny>> {
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<i64> {
match p {
None => Ok(0),
Some(s) => s.parse::<i64>().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<Py<PyAny>> {
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<String, JSValue>,
) -> PyResult<Option<Py<PyAny>>> {
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<'_>,
Expand Down Expand Up @@ -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);
}
}
_ => {}
}
}
Expand Down Expand Up @@ -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::<PyDate>() {
// Pure date (datetime is matched above); becomes Temporal.PlainDate.
add_bytes(16, tracker)?;
let field = |name: &str| -> PyResult<i64> { py_date.getattr(name)?.extract::<i64>() };
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::<PyTime>() {
// 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<i64> { py_time.getattr(name)?.extract::<i64>() };
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::<PyDelta>() {
// Becomes Temporal.Duration (exact: timedelta is microsecond-based).
add_bytes(24, tracker)?;
let field = |name: &str| -> PyResult<i128> { py_delta.getattr(name)?.extract::<i128>() };
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::<bool>() {
add_bytes(1, tracker)?;
Ok(JSValue::Bool(b))
Expand Down
Loading