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
1 change: 1 addition & 0 deletions news/2043.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix the parsing of leap seconds (``time-second = 60`` per RFC 5545): :meth:`vTime.from_ical <icalendar.prop.dt.time.vTime.from_ical>`, :meth:`vDatetime.from_ical <icalendar.prop.dt.datetime.vDatetime.from_ical>`, :meth:`vTime.parse_jcal_value <icalendar.prop.dt.time.vTime.parse_jcal_value>` and :meth:`vDatetime.parse_jcal_value <icalendar.prop.dt.datetime.vDatetime.parse_jcal_value>` now clamp the value to 59 like the xCal paths instead of raising a parsing error. I used AI to assist with designing this change and reviewing the tests and implementation. @rahul05ranjan
13 changes: 12 additions & 1 deletion src/icalendar/prop/dt/datetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,13 +150,19 @@ def from_ical(ical, timezone=None):
)

try:
second = int(ical_value[13:15])
if second == 60:
# RFC 5545 permits time-second = 00-60 to account for positive
# leap seconds. This is wrong but we have no way to represent
# this, so we clamp it like the xCal path does.
second = 59
timetuple = (
int(ical_value[:4]), # year
int(ical_value[4:6]), # month
int(ical_value[6:8]), # day
int(ical_value[9:11]), # hour
int(ical_value[11:13]), # minute
int(ical_value[13:15]), # second
second,
)
if tzinfo:
return tzp.localize(datetime(*timetuple), tzinfo)
Expand Down Expand Up @@ -207,6 +213,11 @@ def parse_jcal_value(cls, jcal: str) -> datetime:
utc = jcal.endswith("Z")
if utc:
jcal = jcal[:-1]
# RFC 5545 permits time-second = 00-60 to account for positive
# leap seconds. This is wrong but we have no way to represent
# this, so we clamp it like the xCal path does.
if jcal.endswith(":60"):
jcal = jcal[:-2] + "59"
try:
dt = datetime.strptime(jcal, "%Y-%m-%dT%H:%M:%S")
except ValueError as e:
Expand Down
26 changes: 20 additions & 6 deletions src/icalendar/prop/dt/time.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,13 @@ def from_ical(ical: str, timezone: str | None | tzinfo = None) -> time:
)

try:
result = time(int(ical[:2]), int(ical[2:4]), int(ical[4:6]))
second = int(ical[4:6])
if second == 60:
# RFC 5545 permits time-second = 00-60 to account for positive
# leap seconds. This is wrong but we have no way to represent
# this, so we clamp it like the xCal path does.
second = 59
result = time(int(ical[:2]), int(ical[2:4]), second)
except Exception as e:
raise ICalParsingError(
"Expected time",
Expand Down Expand Up @@ -246,11 +252,19 @@ def parse_jcal_value(cls, jcal: str) -> time:
match = TIME_JCAL_REGEX.match(jcal)
if match is None:
raise JCalParsingError("Cannot parse time.", cls, value=jcal)
hour = int(match.group("hour"))
minute = int(match.group("minute"))
second = int(match.group("second"))
utc = bool(match.group("utc"))
return time(hour, minute, second, tzinfo=timezone.utc if utc else None)
try:
hour = int(match.group("hour"))
minute = int(match.group("minute"))
second = int(match.group("second"))
if second == 60:
# RFC 5545 permits time-second = 00-60 to account for positive
# leap seconds. This is wrong but we have no way to represent
# this, so we clamp it like the xCal path does.
second = 59
utc = bool(match.group("utc"))
return time(hour, minute, second, tzinfo=timezone.utc if utc else None)
except ValueError as e:
raise JCalParsingError("Cannot parse time.", cls, value=jcal) from e

@classmethod
def from_jcal(cls, jcal_property: list) -> Self:
Expand Down
23 changes: 23 additions & 0 deletions src/icalendar/tests/prop/test_date_and_time.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,26 @@ def test_reformed_UTC_serialization(events):
# TODO: the UTC parameter is special - it can be present but should not be serialized
# How should we treat the parameter?
# I think: have it there, but do not serialize it


@pytest.mark.parametrize("value", ["20161231T235960Z", "20161231T235960"])
def test_vdatetime_from_ical_accepts_leap_second(value):
"""A time-second of 60 is valid RFC 5545 (leap seconds) and is clamped to 59.

RFC 5545 section 3.3.5 defines time-second as 00-60; the xCal path of
vDatetime already clamps 60 to 59. The iCalendar path must accept it too
instead of raising ``ValueError: Wrong datetime format``.
"""
parsed = vDatetime.from_ical(value)
assert (parsed.year, parsed.hour, parsed.minute, parsed.second) == (
2016,
23,
59,
59,
)


def test_vdatetime_parse_jcal_value_accepts_leap_second():
"""A jCal time-second of 60 is valid RFC 5545/7265 (leap seconds) and clamps to 59."""
parsed = vDatetime.parse_jcal_value("2016-12-31T23:59:60Z")
assert (parsed.hour, parsed.minute, parsed.second) == (23, 59, 59)
33 changes: 32 additions & 1 deletion src/icalendar/tests/test_time.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import pytest

from icalendar import Calendar
from icalendar.error import ICalParsingError
from icalendar.error import ICalParsingError, JCalParsingError
from icalendar.prop.dt.time import vTime


Expand Down Expand Up @@ -100,3 +100,34 @@ def test_localized_time_utc(tzp):

assert isinstance(localized, time)
assert str(localized.tzinfo) == "UTC"


@pytest.mark.parametrize(
"value",
[
"235960Z",
"235960",
"TZID=America/New_York:235960",
],
)
def test_from_ical_accepts_leap_second(value):
"""A time-second of 60 is valid RFC 5545 (leap seconds) and is clamped to 59.

RFC 5545 section 3.3.12 defines time-second as 00-60; the xCal path of
vTime already clamps 60 to 59. The iCalendar path must accept it too
instead of raising ICalParsingError.
"""
parsed = vTime.from_ical(value)
assert (parsed.hour, parsed.minute, parsed.second) == (23, 59, 59)


def test_parse_jcal_value_accepts_leap_second():
"""A jCal time-second of 60 is valid RFC 5545/7265 (leap seconds) and clamps to 59."""
parsed = vTime.parse_jcal_value("23:59:60Z")
assert (parsed.hour, parsed.minute, parsed.second) == (23, 59, 59)


def test_parse_jcal_value_still_rejects_out_of_range():
"""A second beyond the RFC's 00-60 range is rejected."""
with pytest.raises(JCalParsingError):
vTime.parse_jcal_value("23:59:61")
Loading