diff --git a/crates/monty/src/heap.rs b/crates/monty/src/heap.rs index 8757f7102..54e78b38c 100644 --- a/crates/monty/src/heap.rs +++ b/crates/monty/src/heap.rs @@ -26,7 +26,7 @@ use crate::{ types::{ Bytes, Dataclass, Dict, DictItemsView, DictKeysView, DictValuesView, FrozenSet, List, LongInt, Module, MontyIter, NamedTuple, Path, Range, ReMatch, RePattern, Set, Slice, Str, TimeZone, Tuple, allocate_tuple, date, - datetime, timedelta, timezone, + datetime, time, timedelta, timezone, }, value::Value, }; @@ -88,6 +88,7 @@ impl HashState { | HeapData::LongInt(_) | HeapData::Date(_) | HeapData::DateTime(_) + | HeapData::Time(_) | HeapData::TimeDelta(_) | HeapData::TimeZone(_) => Self::Unknown, // Dataclass hashability depends on the mutable flag @@ -232,6 +233,7 @@ impl<'a, T: ResourceTracker> HeapReader<'a, T> { HeapData::ReMatch(re_match) => HeapReadOutput::ReMatch(heap_read(base, re_match, readers)), HeapData::Date(d) => HeapReadOutput::Date(heap_read(base, d, readers)), HeapData::DateTime(d) => HeapReadOutput::DateTime(heap_read(base, d, readers)), + HeapData::Time(t) => HeapReadOutput::Time(heap_read(base, t, readers)), HeapData::TimeDelta(d) => HeapReadOutput::TimeDelta(heap_read(base, d, readers)), HeapData::TimeZone(d) => HeapReadOutput::TimeZone(heap_read(base, d, readers)), } @@ -317,6 +319,7 @@ pub enum HeapReadOutput<'a> { ReMatch(HeapRead<'a, ReMatch>), Date(HeapRead<'a, date::Date>), DateTime(HeapRead<'a, datetime::DateTime>), + Time(HeapRead<'a, time::Time>), TimeDelta(HeapRead<'a, timedelta::TimeDelta>), TimeZone(HeapRead<'a, timezone::TimeZone>), } @@ -1408,6 +1411,12 @@ fn compute_hash_from_read<'h>( d.get(vm.heap).hash(&mut hasher); Ok(Some(hasher.finish())) } + HeapReadOutput::Time(t) => { + let mut hasher = DefaultHasher::new(); + heap_disc(vm.heap, id).hash(&mut hasher); + t.get(vm.heap).hash(&mut hasher); + Ok(Some(hasher.finish())) + } HeapReadOutput::TimeDelta(d) => { let mut hasher = DefaultHasher::new(); heap_disc(vm.heap, id).hash(&mut hasher); diff --git a/crates/monty/src/heap_data.rs b/crates/monty/src/heap_data.rs index a7bf53c60..bc1a278f2 100644 --- a/crates/monty/src/heap_data.rs +++ b/crates/monty/src/heap_data.rs @@ -14,7 +14,7 @@ use crate::{ types::{ Bytes, Dataclass, Dict, DictItemsView, DictKeysView, DictValuesView, FrozenSet, List, LongInt, Module, MontyIter, NamedTuple, Path, PyTrait, Range, ReMatch, RePattern, Set, Slice, Str, Tuple, Type, date, datetime, - dict_view::DictView, timedelta, timezone, + dict_view::DictView, time, timedelta, timezone, }, value::{EitherStr, Value}, }; @@ -119,6 +119,8 @@ pub(crate) enum HeapData { Date(date::Date), /// A `datetime.datetime` value stored with chrono primitives. DateTime(datetime::DateTime), + /// A `datetime.time` value stored with narrow integer fields. + Time(time::Time), /// A `datetime.timedelta` duration value stored with `chrono::TimeDelta`. TimeDelta(timedelta::TimeDelta), /// A fixed-offset `datetime.timezone` value. @@ -238,6 +240,7 @@ impl HeapData { Self::ReMatch(_) => Type::ReMatch, Self::Date(_) => Type::Date, Self::DateTime(_) => Type::DateTime, + Self::Time(_) => Type::Time, Self::TimeDelta(_) => Type::TimeDelta, Self::TimeZone(_) => Type::TimeZone, } @@ -274,6 +277,7 @@ impl HeapData { Self::ExtFunction(s) => mem::size_of::() + s.len(), Self::Date(d) => d.py_estimate_size(), Self::DateTime(d) => d.py_estimate_size(), + Self::Time(t) => t.py_estimate_size(), Self::TimeDelta(d) => d.py_estimate_size(), Self::TimeZone(d) => d.py_estimate_size(), } @@ -449,7 +453,7 @@ impl<'h> PyTrait<'h> for HeapReadOutput<'h> { Self::ReMatch(m) => m.py_bool(vm), Self::RePattern(p) => p.py_bool(vm), Self::TimeDelta(td) => td.py_bool(vm), - Self::Date(_) | Self::DateTime(_) | Self::TimeZone(_) => true, + Self::Date(_) | Self::DateTime(_) | Self::Time(_) | Self::TimeZone(_) => true, } } @@ -479,6 +483,7 @@ impl<'h> PyTrait<'h> for HeapReadOutput<'h> { HeapReadOutput::TimeDelta(td) => Ok(td.py_call_attr(self_id, vm, attr, args)?), HeapReadOutput::Date(d) => Ok(d.py_call_attr(self_id, vm, attr, args)?), HeapReadOutput::DateTime(dt) => Ok(dt.py_call_attr(self_id, vm, attr, args)?), + HeapReadOutput::Time(t) => Ok(t.py_call_attr(self_id, vm, attr, args)?), // Types without methods — return AttributeError _ => { args.drop_with_heap(vm); @@ -516,6 +521,7 @@ impl<'h> PyTrait<'h> for HeapReadOutput<'h> { Self::RePattern(p) => p.py_type(vm), Self::Date(d) => d.py_type(vm), Self::DateTime(d) => d.py_type(vm), + Self::Time(t) => t.py_type(vm), Self::TimeDelta(d) => d.py_type(vm), Self::TimeZone(d) => d.py_type(vm), } @@ -647,6 +653,7 @@ impl<'h> PyTrait<'h> for HeapReadOutput<'h> { // Datetime types (HeapReadOutput::Date(a), HeapReadOutput::Date(b)) => a.py_eq(b, vm), (HeapReadOutput::DateTime(a), HeapReadOutput::DateTime(b)) => a.py_eq(b, vm), + (HeapReadOutput::Time(a), HeapReadOutput::Time(b)) => a.py_eq(b, vm), (HeapReadOutput::TimeDelta(a), HeapReadOutput::TimeDelta(b)) => a.py_eq(b, vm), (HeapReadOutput::TimeZone(a), HeapReadOutput::TimeZone(b)) => a.py_eq(b, vm), // Identity-only types (handled by HeapId comparison above) @@ -713,6 +720,7 @@ impl<'h> PyTrait<'h> for HeapReadOutput<'h> { Self::ExtFunction(name) => Ok(write!(f, "", name.get(vm.heap))?), Self::Date(d) => d.py_repr_fmt(f, vm, heap_ids), Self::DateTime(d) => d.py_repr_fmt(f, vm, heap_ids), + Self::Time(t) => t.py_repr_fmt(f, vm, heap_ids), Self::TimeDelta(d) => d.py_repr_fmt(f, vm, heap_ids), Self::TimeZone(d) => d.py_repr_fmt(f, vm, heap_ids), } @@ -735,6 +743,7 @@ impl<'h> PyTrait<'h> for HeapReadOutput<'h> { // Datetime types have their own str output Self::Date(d) => d.py_str(vm), Self::DateTime(d) => d.py_str(vm), + Self::Time(t) => t.py_str(vm), Self::TimeDelta(d) => d.py_str(vm), Self::TimeZone(d) => d.py_str(vm), // All other types use repr @@ -912,6 +921,7 @@ impl<'h> PyTrait<'h> for HeapReadOutput<'h> { Self::Path(p) => p.py_getattr(attr, vm), Self::Date(d) => d.py_getattr(attr, vm), Self::DateTime(dt) => dt.py_getattr(attr, vm), + Self::Time(t) => t.py_getattr(attr, vm), Self::TimeDelta(td) => td.py_getattr(attr, vm), _ => Ok(None), } diff --git a/crates/monty/src/intern.rs b/crates/monty/src/intern.rs index 77e9b2e6c..edc798b8b 100644 --- a/crates/monty/src/intern.rs +++ b/crates/monty/src/intern.rs @@ -469,6 +469,8 @@ pub enum StaticStrings { // datetime module strings Datetime, Date, + /// `datetime.time` class name and attribute on `datetime.datetime`. + Time, Timedelta, Timezone, Today, @@ -484,6 +486,8 @@ pub enum StaticStrings { Minute, Second, Microsecond, + /// `datetime.time.fold` attribute / `time(fold=...)` keyword. + Fold, // timedelta constructor/attribute names Days, Seconds, diff --git a/crates/monty/src/modules/datetime.rs b/crates/monty/src/modules/datetime.rs index 800bbc400..83813b0e5 100644 --- a/crates/monty/src/modules/datetime.rs +++ b/crates/monty/src/modules/datetime.rs @@ -3,6 +3,7 @@ //! This module exposes a minimal phase-1 surface: //! - `date` //! - `datetime` +//! - `time` //! - `timedelta` //! - `timezone` //! @@ -35,6 +36,7 @@ pub fn create_module(vm: &mut VM<'_, '_, impl ResourceTracker>) -> Result repr_or_error(object, vm), // Cells are internal closure implementation details HeapData::Cell(cell) => { // Show the cell's contents diff --git a/crates/monty/src/types/mod.rs b/crates/monty/src/types/mod.rs index 1f9b8024e..372353d60 100644 --- a/crates/monty/src/types/mod.rs +++ b/crates/monty/src/types/mod.rs @@ -25,6 +25,7 @@ pub mod re_pattern; pub mod set; pub mod slice; pub mod str; +pub mod time; pub mod timedelta; pub mod timezone; pub mod tuple; diff --git a/crates/monty/src/types/time.rs b/crates/monty/src/types/time.rs new file mode 100644 index 000000000..a11fda000 --- /dev/null +++ b/crates/monty/src/types/time.rs @@ -0,0 +1,564 @@ +//! Python `datetime.time` implementation. +//! +//! Monty stores times as `(hour, minute, second, microsecond, fold, tzinfo)` and +//! layers CPython-compatible constructor validation, repr/str formatting, and +//! comparison semantics. Like `datetime.datetime`, an optional `tzinfo` may be +//! attached as a `TimeZone` instance — CPython's full `tzinfo` ABC is not +//! supported in phase 1, matching the existing `datetime.datetime` scope. +//! +//! This is phase-1 only: the full CPython surface (`replace`, `strftime`, +//! `fromisoformat`, `utcoffset`, `tzname`, `dst`, `__format__`) is not yet +//! implemented. Minimum viable surface: +//! - Constructor with validation and `tzinfo`/`fold` +//! - Attribute access: `hour`, `minute`, `second`, `microsecond`, `tzinfo`, `fold` +//! - `__eq__`, `__lt__`/`__le__`/`__gt__`/`__ge__`, `__hash__`, `__repr__`, `__str__` +//! - `isoformat()` +//! +//! Like `datetime.datetime`, aware and naive time values never compare equal +//! (they also cannot be ordered against each other). Two aware times with the +//! same offset are compared by their local clock fields, *not* by shifting to +//! UTC, because a bare time has no date to shift against — matching CPython. + +use std::{ + borrow::Cow, + cmp::Ordering, + fmt::Write, + hash::{Hash, Hasher}, + mem, +}; + +use ahash::AHashSet; + +use crate::{ + args::ArgValues, + bytecode::{CallResult, VM}, + defer_drop, defer_drop_mut, + exception_private::{ExcType, RunResult, SimpleException}, + heap::{Heap, HeapData, HeapId, HeapItem, HeapRead}, + intern::{Interns, StaticStrings}, + resource::{ResourceError, ResourceTracker}, + types::{ + PyTrait, TimeZone, Type, + str::{Str, StringRepr}, + timezone, value_to_i32, + }, + value::{EitherStr, Value}, +}; + +/// `datetime.time` storage. +/// +/// Time fields are kept in the narrowest integer widths that can represent the +/// validated ranges, keeping the struct compact. `offset_seconds` + `timezone_name` +/// mirror `DateTime`'s fixed-offset tzinfo representation, and `tzinfo_ref` +/// preserves the original `tzinfo` object identity so `t.tzinfo is input_tz` +/// works across attribute access, matching CPython. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub(crate) struct Time { + /// Hour in `0..=23`. + hour: u8, + /// Minute in `0..=59`. + minute: u8, + /// Second in `0..=59`. + second: u8, + /// Microsecond in `0..=999_999`. + microsecond: u32, + /// Fold flag: 0 or 1. Used by CPython to disambiguate repeated wall clocks + /// during DST fall-back transitions; Monty stores it for round-trip fidelity + /// but does not interpret it. + fold: u8, + /// Fixed UTC offset seconds for aware times (`None` for naive). + offset_seconds: Option, + /// Optional display name for the attached timezone. + timezone_name: Option, + /// Stable tzinfo object identity for aware times. + /// + /// CPython preserves `t.tzinfo is input_tz` and repeated `t.tzinfo` access + /// returns the same object. We store a retained heap reference so attribute + /// lookup returns a stable object instead of allocating each time. + #[serde(default)] + tzinfo_ref: Option, +} + +impl PartialEq for Time { + fn eq(&self, other: &Self) -> bool { + // Like CPython, aware and naive times never compare equal. For aware + // pairs we compare by offset-adjusted microseconds — but because `time` + // has no date, this is equivalent to comparing local-clock fields when + // the offsets match. CPython does normalize by subtracting offsets, so + // we do the same here to handle two aware times with different offsets + // that represent the same wall-clock instant. + if self.offset_seconds.is_some() != other.offset_seconds.is_some() { + return false; + } + micros_of_day_adjusted(self) == micros_of_day_adjusted(other) + } +} + +impl Eq for Time {} + +impl Hash for Time { + fn hash(&self, state: &mut H) { + // Hash must be consistent with equality: aware times hash by + // offset-adjusted microseconds; naive times hash by local microseconds. + // Whether the time is aware is mixed in so that aware/naive with the + // same numeric micros still get different hashes (though equality + // already rules them out). + self.offset_seconds.is_some().hash(state); + micros_of_day_adjusted(self).hash(state); + } +} + +/// Returns the microsecond-of-day minus the offset (if aware) as an `i64`. +/// +/// Used by equality and hashing so two aware times with different offsets but +/// the same UTC-equivalent wall clock compare equal. Uses `i64` to comfortably +/// hold 86_400_000_000 ± 86399 * 1_000_000. +fn micros_of_day_adjusted(time: &Time) -> i64 { + let local = i64::from(time.hour) * 3_600_000_000 + + i64::from(time.minute) * 60_000_000 + + i64::from(time.second) * 1_000_000 + + i64::from(time.microsecond); + match time.offset_seconds { + Some(offset) => local - i64::from(offset) * 1_000_000, + None => local, + } +} + +/// Constructor for `time(hour=0, minute=0, second=0, microsecond=0, tzinfo=None, *, fold=0)`. +/// +/// Matches CPython's argument semantics: all components default to 0, `tzinfo` +/// defaults to `None` and may be `None` or a `TimeZone` instance, and `fold` is +/// keyword-only with a default of 0 (must be 0 or 1). Raises `ValueError` when +/// any component is out of range, and `TypeError` for unknown keywords or a +/// non-tzinfo value for `tzinfo`. +pub(crate) fn init(heap: &mut Heap, args: ArgValues, interns: &Interns) -> RunResult { + let (pos, kwargs) = args.into_parts(); + defer_drop_mut!(pos, heap); + let kwargs = kwargs.into_iter(); + defer_drop_mut!(kwargs, heap); + // Keep the provided tzinfo object alive across argument parsing so we can + // safely retain its identity in the constructed time. + let retained_tzinfo = Value::None; + defer_drop_mut!(retained_tzinfo, heap); + + let mut hour: i32 = 0; + let mut minute: i32 = 0; + let mut second: i32 = 0; + let mut microsecond: i32 = 0; + let mut fold: i32 = 0; + let mut tzinfo: Option = None; + let mut tzinfo_ref: Option = None; + let mut seen_hour = false; + let mut seen_minute = false; + let mut seen_second = false; + let mut seen_microsecond = false; + let mut seen_tzinfo = false; + let mut seen_fold = false; + + for (index, arg) in pos.by_ref().enumerate() { + defer_drop!(arg, heap); + match index { + 0 => { + hour = value_to_i32(arg)?; + seen_hour = true; + } + 1 => { + minute = value_to_i32(arg)?; + seen_minute = true; + } + 2 => { + second = value_to_i32(arg)?; + seen_second = true; + } + 3 => { + microsecond = value_to_i32(arg)?; + seen_microsecond = true; + } + 4 => { + let (value_tzinfo, value_tzinfo_ref) = tzinfo_from_value(arg, heap)?; + update_retained_tzinfo(retained_tzinfo, value_tzinfo_ref, heap); + tzinfo = value_tzinfo; + tzinfo_ref = value_tzinfo_ref; + seen_tzinfo = true; + } + _ => { + return Err(SimpleException::new_msg( + ExcType::TypeError, + format!("function takes at most 5 positional arguments ({} given)", index + 1), + ) + .into()); + } + } + } + + for (key, value) in kwargs { + defer_drop!(key, heap); + defer_drop!(value, heap); + let Some(key_name) = key.as_either_str(heap) else { + return Err(ExcType::type_error_kwargs_nonstring_key()); + }; + match key_name.string_id() { + Some(id) if id == StaticStrings::Hour => { + if seen_hour { + return Err(ExcType::type_error_positional_keyword_conflict("function", "hour", 1)); + } + hour = value_to_i32(value)?; + seen_hour = true; + } + Some(id) if id == StaticStrings::Minute => { + if seen_minute { + return Err(ExcType::type_error_positional_keyword_conflict("function", "minute", 2)); + } + minute = value_to_i32(value)?; + seen_minute = true; + } + Some(id) if id == StaticStrings::Second => { + if seen_second { + return Err(ExcType::type_error_positional_keyword_conflict("function", "second", 3)); + } + second = value_to_i32(value)?; + seen_second = true; + } + Some(id) if id == StaticStrings::Microsecond => { + if seen_microsecond { + return Err(ExcType::type_error_positional_keyword_conflict( + "function", + "microsecond", + 4, + )); + } + microsecond = value_to_i32(value)?; + seen_microsecond = true; + } + Some(id) if id == StaticStrings::Tzinfo => { + if seen_tzinfo { + return Err(ExcType::type_error_positional_keyword_conflict("function", "tzinfo", 5)); + } + let (value_tzinfo, value_tzinfo_ref) = tzinfo_from_value(value, heap)?; + update_retained_tzinfo(retained_tzinfo, value_tzinfo_ref, heap); + tzinfo = value_tzinfo; + tzinfo_ref = value_tzinfo_ref; + seen_tzinfo = true; + } + Some(id) if id == StaticStrings::Fold => { + if seen_fold { + return Err(ExcType::type_error_positional_keyword_conflict("function", "fold", 6)); + } + fold = value_to_i32(value)?; + seen_fold = true; + } + _ => { + return Err(ExcType::type_error_c_unexpected_keyword(key_name.as_str(interns))); + } + } + } + + let time = from_components(hour, minute, second, microsecond, fold, tzinfo, tzinfo_ref, heap)?; + Ok(Value::Ref(heap.allocate(HeapData::Time(time))?)) +} + +/// Creates a `Time` from validated civil components and optional tzinfo. +/// +/// Validates each component's range with CPython-compatible error messages and +/// allocates a stable `tzinfo_ref` when the provided timezone object isn't +/// preserved verbatim. +#[expect(clippy::too_many_arguments)] +fn from_components( + hour: i32, + minute: i32, + second: i32, + microsecond: i32, + fold: i32, + tzinfo: Option, + tzinfo_ref: Option, + heap: &mut Heap, +) -> RunResult