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
2 changes: 1 addition & 1 deletion .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ jobs:
toolchain: "1.86"

- name: Add win32 target
if: ${{ matrix.os == 'windows' }}
if: ${{ matrix.os == 'windows' && !matrix.no_build_ext }}
run: rustup target add i686-pc-windows-msvc

- uses: actions/setup-python@v5
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# 🚀 Changelog

## 0.9.3 (2025-10-16)

Fixed incorrect offsets for some timezones before the start of their first
recorded transition (typically pre-1950) (#296)

## 0.9.2 (2025-09-29)

Methods that take an `int`, `float`, or `str` now also accept subclasses of these types.
Expand Down
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
13 changes: 4 additions & 9 deletions docs/faq.rst
Original file line number Diff line number Diff line change
Expand Up @@ -92,15 +92,10 @@ 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.
``whenever`` does not account for leap seconds.
However, it does parse timestamps with a second component of 60, truncating them to 59.
For example, ``23:59:60`` is treated as ``23:59:59``.
This is consistent with other modern datetime libraries.

.. _faq-why-not-dropin:

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ maintainers = [
{name = "Arie Bovenberg", email = "a.c.bovenberg@gmail.com"},
]
readme = "README.md"
version = "0.9.2"
version = "0.9.3"
license = "MIT"
description = "Modern datetime library for Python"
requires-python = ">=3.9"
Expand Down
2 changes: 1 addition & 1 deletion pysrc/whenever/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@

# Yes, we could get the version with importlib.metadata,
# but we try to keep our import time as low as possible.
__version__ = "0.9.2"
__version__ = "0.9.3"

reset_tzpath() # populate the tzpath once at startup

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 @@ -6035,12 +6044,14 @@ def _parse_rfc2822(s: str) -> _datetime:
# time components may be separated by whitespace
*time_parts, offset_raw = parts
time_raw = "".join(time_parts)
# Normalize leap seconds (60) to 59
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:])
)
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
9 changes: 6 additions & 3 deletions pysrc/whenever/_tz/tzif.py
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,11 @@ def _load_transitions(
) -> Sequence[tuple[EpochSecs, Offset]]:
"""Load transitions from parsed data"""
return [
(epoch, offsets[idx]) for idx, epoch in zip(indices, transition_times)
(EPOCH_SECS_MIN, offsets[0]), # Ensure correct initial offset
*(
(epoch, offsets[idx])
for idx, epoch in zip(indices, transition_times)
),
]


Expand All @@ -331,8 +335,7 @@ def _local_transitions(
transitions: Sequence[tuple[EpochSecs, Offset]],
) -> Sequence[tuple[EpochSecs, tuple[Offset, OffsetDelta]]]:
result: list[tuple[EpochSecs, tuple[Offset, OffsetDelta]]] = []
if not transitions:
return result
assert transitions # we've ensured there's at least one transition

(_, offset_prev), *remaining = transitions
for epoch, offset in remaining:
Expand Down
147 changes: 145 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,7 +119,7 @@ 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)),
None => (0, SubSecNanos::MIN),
};
Expand Down Expand Up @@ -745,4 +745,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());
}
}
Loading
Loading