Skip to content
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,18 +186,17 @@ or [API reference](https://whenever.readthedocs.io/en/latest/api.html).
- ✅ Deltas
- ✅ Date and time of day (separate from datetime)
- ✅ Implement Rust extension for performance
- ✅ Parsing leap seconds
- 🚧 Tweaks to the delta API
- 🔒 **1.0**: API stability and backwards compatibility
- 🚧 Customizable parsing and formatting
- 🚧 Intervals
- 🚧 Ranges and recurring times
- 🚧 Parsing leap seconds

## Limitations

- Supports the proleptic Gregorian calendar between 1 and 9999 AD
- Timezone offsets are limited to whole seconds (consistent with IANA TZ DB)
- No support for leap seconds (consistent with industry standards and other modern libraries)

## Versioning and compatibility policy

Expand Down
12 changes: 3 additions & 9 deletions docs/faq.rst
Original file line number Diff line number Diff line change
Expand Up @@ -92,15 +92,9 @@ Common critiques of ``PlainDateTime`` are:
Are leap seconds supported?
~~~~~~~~~~~~~~~~~~~~~~~~~~~

Leap seconds are unsupported.
Taking leap seconds into account is a complex and niche feature,
which is not needed for the vast majority of applications.
This decision is consistent with other modern libraries
(e.g. NodaTime, Temporal) and standards (RFC 5545, Unix time) which
do not support leap seconds.

One improvement that is planned: allowing the parsing of leap seconds,
which are then truncated to 59 seconds.
Leap seconds are now supported by truncating them away.
This means that a leap second is treated as if it were the same as the
preceding second. For example, ``23:59:60`` is treated as ``23:59:59``.

.. _faq-why-not-dropin:

Expand Down
17 changes: 14 additions & 3 deletions pysrc/whenever/_pywhenever.py
Original file line number Diff line number Diff line change
Expand Up @@ -5886,6 +5886,9 @@ def __time_from_iso_nofrac(s: str) -> _time:
if s.count(":") > 2:
raise ValueError()
if all(map("0123456789:".__contains__, s)):
# Normalize leap seconds (60) to 59
if s.endswith("60"):
s = s[:-2] + "59"
return _time.fromisoformat(s)
raise ValueError()

Expand All @@ -5909,8 +5912,14 @@ def __time_from_iso_nofrac(s: str) -> _time:
if len(s) == 4:
s = s[:2] + ":" + s[2:]
elif len(s) == 6:
# Normalize leap seconds (60) to 59 in basic format
if s.endswith("60"):
s = s[:4] + "59"
s = s[:2] + ":" + s[2:4] + ":" + s[4:]
if all(map("0123456789:".__contains__, s)):
# Normalize leap seconds (60) to 59 in extended format
if s.endswith(":60"):
s = s[:-2] + "59"
return _time.fromisoformat(s)
raise ValueError()

Expand Down Expand Up @@ -6038,9 +6047,11 @@ def _parse_rfc2822(s: str) -> _datetime:
if len(time_raw) == 5 and time_raw[2] == ":":
time = _time(int(time_raw[:2]), int(time_raw[3:]))
elif len(time_raw) == 8 and time_raw[2] == ":" and time_raw[5] == ":":
time = _time(
int(time_raw[:2]), int(time_raw[3:5]), int(time_raw[6:])
)
# Normalize leap seconds (60) to 59
seconds = int(time_raw[6:])
if seconds == 60:
seconds = 59
time = _time(int(time_raw[:2]), int(time_raw[3:5]), seconds)
else:
_parse_err(s)
except ValueError:
Expand Down
149 changes: 147 additions & 2 deletions src/classes/time.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ impl Time {
let min = s.digits00_59()?;
// seconds are still optional at this point
let (sec, subsec) = match s.advance_on(b':') {
Some(true) => s.digits00_59().zip(s.subsec())?,
Some(true) => s.digits00_60_leap().zip(s.subsec())?,
_ => (0, SubSecNanos::MIN),
};
(min, sec, subsec)
Expand All @@ -119,8 +119,10 @@ impl Time {
let hour = s.digits00_23()?;
let (minute, second, subsec) = match s.digits00_59() {
Some(m) => {
let (sec, sub) = match s.digits00_59() {
let (sec, sub) = match s.digits00_60_leap() {
Some(n) => (n, s.subsec().unwrap_or(SubSecNanos::MIN)),
// If there are more digits but they're not valid seconds, fail the parse
None if s.peek().is_some_and(|c| c.is_ascii_digit()) => return None,
None => (0, SubSecNanos::MIN),
};
(m, sec, sub)
Expand Down Expand Up @@ -745,4 +747,147 @@ mod tests {
testcase(t4, false, fmt::Unit::Hour, b"12");
testcase(t4, true, fmt::Unit::Hour, b"12");
}

#[test]
fn parse_leap_seconds_extended_format() {
// Leap second normalization in extended format
let t = Time::parse_iso(b"01:02:60").unwrap();
assert_eq!(t.hour, 1);
assert_eq!(t.minute, 2);
assert_eq!(t.second, 59);
assert_eq!(t.subsec, SubSecNanos::MIN);

// With fractional seconds
let t = Time::parse_iso(b"23:59:60.999999999").unwrap();
assert_eq!(t.hour, 23);
assert_eq!(t.minute, 59);
assert_eq!(t.second, 59);
assert_eq!(t.subsec, SubSecNanos::new_unchecked(999_999_999));

let t = Time::parse_iso(b"12:34:60.123456").unwrap();
assert_eq!(t.hour, 12);
assert_eq!(t.minute, 34);
assert_eq!(t.second, 59);
assert_eq!(t.subsec, SubSecNanos::new_unchecked(123_456_000));

// Comma as decimal separator
let t = Time::parse_iso(b"12:34:60,5").unwrap();
assert_eq!(t.hour, 12);
assert_eq!(t.minute, 34);
assert_eq!(t.second, 59);
assert_eq!(t.subsec, SubSecNanos::new_unchecked(500_000_000));
}

#[test]
fn parse_leap_seconds_basic_format() {
// Leap second normalization in basic format
let t = Time::parse_iso(b"010260").unwrap();
assert_eq!(t.hour, 1);
assert_eq!(t.minute, 2);
assert_eq!(t.second, 59);
assert_eq!(t.subsec, SubSecNanos::MIN);

// With fractional seconds
let t = Time::parse_iso(b"235960.999999999").unwrap();
assert_eq!(t.hour, 23);
assert_eq!(t.minute, 59);
assert_eq!(t.second, 59);
assert_eq!(t.subsec, SubSecNanos::new_unchecked(999_999_999));

let t = Time::parse_iso(b"123460.123456").unwrap();
assert_eq!(t.hour, 12);
assert_eq!(t.minute, 34);
assert_eq!(t.second, 59);
assert_eq!(t.subsec, SubSecNanos::new_unchecked(123_456_000));

// Comma as decimal separator
let t = Time::parse_iso(b"123460,5").unwrap();
assert_eq!(t.hour, 12);
assert_eq!(t.minute, 34);
assert_eq!(t.second, 59);
assert_eq!(t.subsec, SubSecNanos::new_unchecked(500_000_000));
}

#[test]
fn parse_leap_seconds_edge_cases() {
// Midnight leap second
let t = Time::parse_iso(b"00:00:60").unwrap();
assert_eq!(t.hour, 0);
assert_eq!(t.minute, 0);
assert_eq!(t.second, 59);

// End of day leap second
let t = Time::parse_iso(b"23:59:60").unwrap();
assert_eq!(t.hour, 23);
assert_eq!(t.minute, 59);
assert_eq!(t.second, 59);

// Various minutes with leap seconds
for minute in 0..60 {
let input = format!("12:{:02}:60", minute);
let t = Time::parse_iso(input.as_bytes()).unwrap();
assert_eq!(t.hour, 12);
assert_eq!(t.minute, minute);
assert_eq!(t.second, 59);
}
}

#[test]
fn parse_invalid_seconds() {
// 61 and above should be rejected
assert!(Time::parse_iso(b"01:02:61").is_none());
assert!(Time::parse_iso(b"01:02:62").is_none());
assert!(Time::parse_iso(b"01:02:99").is_none());
assert!(Time::parse_iso(b"010261").is_none());
assert!(Time::parse_iso(b"010262").is_none());
assert!(Time::parse_iso(b"010299").is_none());
}

#[test]
fn parse_normal_seconds_still_work() {
// Ensure normal seconds 00-59 still parse correctly
for sec in 0..60 {
let input = format!("12:34:{:02}", sec);
let t = Time::parse_iso(input.as_bytes()).unwrap();
assert_eq!(t.hour, 12);
assert_eq!(t.minute, 34);
assert_eq!(t.second, sec);

let input = format!("1234{:02}", sec);
let t = Time::parse_iso(input.as_bytes()).unwrap();
assert_eq!(t.hour, 12);
assert_eq!(t.minute, 34);
assert_eq!(t.second, sec);
}
}

#[test]
fn read_iso_extended_leap_seconds() {
// Test the read_iso_extended function directly
let mut scan = Scan::new(b"12:34:60");
let t = Time::read_iso_extended(&mut scan).unwrap();
assert_eq!(t.second, 59);
assert!(scan.is_done());

let mut scan = Scan::new(b"12:34:60.123");
let t = Time::read_iso_extended(&mut scan).unwrap();
assert_eq!(t.second, 59);
assert_eq!(t.subsec, SubSecNanos::new_unchecked(123_000_000));
assert!(scan.is_done());
}

#[test]
fn read_iso_basic_leap_seconds() {
// Test the read_iso_basic function directly
let mut scan = Scan::new(b"123460");
let t = Time::read_iso_basic(&mut scan).unwrap();
assert_eq!(t.second, 59);
assert!(scan.is_done());

let mut scan = Scan::new(b"123460.123");
let t = Time::read_iso_basic(&mut scan).unwrap();
assert_eq!(t.second, 59);
assert_eq!(t.subsec, SubSecNanos::new_unchecked(123_000_000));
assert!(scan.is_done());
}
}
80 changes: 80 additions & 0 deletions src/common/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,24 @@ impl<'a> Scan<'a> {
}
}

/// Parse two digits in the range 00-60 (allowing leap seconds).
/// Returns the parsed value, but values of 60 are normalized to 59.
pub(crate) fn digits00_60_leap(&mut self) -> Option<u8> {
match self.0 {
[a @ b'0'..=b'5', b @ b'0'..=b'9', ..] => {
// Normal case: 00-59
self.0 = &self.0[2..];
Some((a - b'0') * 10 + (b - b'0'))
}
[b'6', b'0', ..] => {
// Special case: exactly 60 (leap second) -> normalize to 59
self.0 = &self.0[2..];
Some(59)
}
_ => None,
}
}

/// Parse two digits in the range 00-23.
pub(crate) fn digits00_23(&mut self) -> Option<u8> {
match self.0 {
Expand Down Expand Up @@ -382,6 +400,68 @@ mod tests {
assert_eq!(scan.digits00_59(), None);
}

#[test]
fn test_scan_digits00_60_leap() {
let mut scan = Scan::new(b"12a4560z61");
assert_eq!(scan.digits00_60_leap(), Some(12));
assert_eq!(scan.digits00_60_leap(), None);
scan.expect(b'a');
assert_eq!(scan.digits00_60_leap(), Some(45));
assert_eq!(scan.digits00_60_leap(), Some(59)); // 60 -> 59
scan.expect(b'z');
assert_eq!(scan.digits00_60_leap(), None); // 61 is invalid, not consumed
assert_eq!(scan.rest(), b"61");
}

#[test]
fn test_scan_digits00_60_leap_comprehensive() {
// Test all valid seconds 00-59
for sec in 0..60 {
let input = format!("{:02}", sec);
let mut scan = Scan::new(input.as_bytes());
assert_eq!(scan.digits00_60_leap(), Some(sec as u8));
}

// Test leap second normalization
let mut scan = Scan::new(b"60");
assert_eq!(scan.digits00_60_leap(), Some(59));
assert!(scan.is_done());

// Test invalid values
let mut scan = Scan::new(b"61");
assert_eq!(scan.digits00_60_leap(), None);
assert_eq!(scan.rest(), b"61");

let mut scan = Scan::new(b"62");
assert_eq!(scan.digits00_60_leap(), None);

let mut scan = Scan::new(b"99");
assert_eq!(scan.digits00_60_leap(), None);

// Test edge cases with context
let mut scan = Scan::new(b"6000");
assert_eq!(scan.digits00_60_leap(), Some(59));
assert_eq!(scan.rest(), b"00");

let mut scan = Scan::new(b"5960");
assert_eq!(scan.digits00_60_leap(), Some(59));
assert_eq!(scan.digits00_60_leap(), Some(59));

// Test single digit and incomplete input
let mut scan = Scan::new(b"6");
assert_eq!(scan.digits00_60_leap(), None);

let mut scan = Scan::new(b"");
assert_eq!(scan.digits00_60_leap(), None);

// Test non-digit characters
let mut scan = Scan::new(b"6x");
assert_eq!(scan.digits00_60_leap(), None);

let mut scan = Scan::new(b"x0");
assert_eq!(scan.digits00_60_leap(), None);
}

#[test]
fn test_scan_up_to_3_digits() {
let mut scan = Scan::new(b"1234_k00z92");
Expand Down
2 changes: 1 addition & 1 deletion src/common/rfc2822.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ fn parse_time(s: &mut Scan) -> Option<Time> {
let second = match s.peek()? {
b':' => {
s.skip(1).ascii_whitespace();
let val = s.digits00_59()?;
let val = s.digits00_60_leap()?;
// Whitespace after seconds is required!
s.ascii_whitespace().then_some(())?;
val
Expand Down
Loading
Loading