Skip to content

feat: convert Temporal values to and from Python datetime types - #17

Open
imfing wants to merge 3 commits into
mainfrom
feat/temporal-conversion
Open

imfing wants to merge 3 commits into
mainfrom
feat/temporal-conversion

Conversation

@imfing

@imfing imfing commented Oct 3, 2026

Copy link
Copy Markdown
Owner

Why

The V8 15 upgrade (#10) shipped a mature, Rust-backed Temporal implementation — but any Temporal value crossing into Python arrived as an empty dict {} (its data lives in internal slots, invisible to generic object conversion). Meanwhile Python date/time/timedelta raised Unsupported Python type. This closes both gaps using the package's existing typed-conversion identity.

Mechanism

Temporal values travel as tagged payloads, the same pattern Date/Set/BigInt already use, across all four boundary paths:

JS -> Python:  temporalPrepare(v)  ->  { __jsrun_type: "Temporal", kind, ...fields }
               (bridge prepare() for op args; runner calls the same helper for eval results)
Python -> JS:  date/time/timedelta -> tagged payload -> temporalRevive()
               (bridge revive() for op results; runner calls the helper for call args)

Instant        <-> aware datetime, UTC      (epoch_ns as string; sub-us truncated)
ZonedDateTime  <-> aware datetime           (ZoneInfo, or fixed offset like "+05:30")
PlainDate      <-> date                     (non-ISO calendars normalized via withCalendar)
PlainTime      <-> time
PlainDateTime  <-> naive datetime
Duration       <-> timedelta                (exact; BigInt ns arithmetic)

Blast radius

Area Impact
ops.rs bridge +temporalPrepare/temporalRevive helpers (also exposed as globals for the Rust paths); one hook in prepare(), one case in revive()
runner.rs Eval-result path detects Temporal via the helper; js_value_to_v8 upgrades tagged payloads via the helper
conversion.rs Temporal tag → Python types; new date/time/timedelta branches (previously an error, so purely additive)
datetime behavior Unchanged — still maps to JS Date (back-compat)
Not converted Duration with nonzero years/months/weeks (calendar-relative, no fixed length); aware time raises a clear error
JSValue enum / serde Untouched — tagged-object flow needs no new variants

Validation

  • 21 new tests in tests/test_temporal.py: all six types both directions, ops path, IANA + offset time zones, non-ISO calendars, negative epochs/durations, nesting, round-trips, back-compat.
  • Full suite: 321 passed; make lint clean.

imfing added 3 commits October 3, 2026 16:31
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.
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.
@imfing

imfing commented Oct 7, 2026

Copy link
Copy Markdown
Owner Author

Addressed both review findings (44ca27e):

  • Large durations: dropped the i64 narrowing — total microseconds now pass as an i128 → arbitrary-precision Python int, with timedelta's own constructor enforcing range. timedelta(days=106751992) round-trips.
  • Year-boundary zoned datetimes: the conversion no longer materializes the intermediate UTC datetime. It probes the zone's offset at the instant, then builds local wall-clock fields with integer math (date.fromordinal), so 0001-01-01T00:00:00+01:00[+01:00] and the year-9999 counterpart convert correctly. As a bonus this also fixed DST fall-back folds: ambiguous wall times now carry fold=1 when that occurrence matches the instant's offset (new regression test).

3 new tests; 324 total passing, lint clean.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant