From 01fc827539bffe021780499dc19bf9ecdc05777a Mon Sep 17 00:00:00 2001 From: Rahul Ranjan Date: Wed, 28 Oct 2026 15:09:30 +0530 Subject: [PATCH 1/3] fix(prop): parse leap seconds like the xCal path does AI-assisted change: drafted with DeepSeek-v4-pro (OpenRouter); prompts summarized, output reviewed and validated against the issue. --- news/2043.bugfix | 1 + src/icalendar/prop/dt/datetime.py | 13 +++++++- src/icalendar/prop/dt/time.py | 13 +++++++- .../tests/prop/test_date_and_time.py | 23 ++++++++++++++ src/icalendar/tests/test_time.py | 31 +++++++++++++++++++ 5 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 news/2043.bugfix diff --git a/news/2043.bugfix b/news/2043.bugfix new file mode 100644 index 000000000..910385aa8 --- /dev/null +++ b/news/2043.bugfix @@ -0,0 +1 @@ +Fix the parsing of leap seconds (``time-second = 60`` per RFC 5545): :meth:`vTime.from_ical `, :meth:`vDatetime.from_ical `, :meth:`vTime.parse_jcal_value ` and :meth:`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 \ No newline at end of file diff --git a/src/icalendar/prop/dt/datetime.py b/src/icalendar/prop/dt/datetime.py index fec1c6795..c32c5ad50 100644 --- a/src/icalendar/prop/dt/datetime.py +++ b/src/icalendar/prop/dt/datetime.py @@ -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) @@ -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: diff --git a/src/icalendar/prop/dt/time.py b/src/icalendar/prop/dt/time.py index bed3b10d1..eea1d9432 100644 --- a/src/icalendar/prop/dt/time.py +++ b/src/icalendar/prop/dt/time.py @@ -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", @@ -249,6 +255,11 @@ def parse_jcal_value(cls, jcal: str) -> time: 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) diff --git a/src/icalendar/tests/prop/test_date_and_time.py b/src/icalendar/tests/prop/test_date_and_time.py index 246fd7208..057eae17a 100644 --- a/src/icalendar/tests/prop/test_date_and_time.py +++ b/src/icalendar/tests/prop/test_date_and_time.py @@ -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) diff --git a/src/icalendar/tests/test_time.py b/src/icalendar/tests/test_time.py index 4cc8ed70f..5cd0688c0 100644 --- a/src/icalendar/tests/test_time.py +++ b/src/icalendar/tests/test_time.py @@ -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(Exception): + vTime.parse_jcal_value("23:59:61") From 3457a61d22326bf30799d005087c4047679fecbd Mon Sep 17 00:00:00 2001 From: Rahul Date: Fri, 30 Oct 2026 11:21:31 +0530 Subject: [PATCH 2/3] Assert the specific JCalParsingError instead of Exception (ruff B017) AI-assisted change: drafted with DeepSeek-v4-pro (OpenRouter); prompts summarized, output reviewed and validated against the issue. --- src/icalendar/tests/test_time.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/icalendar/tests/test_time.py b/src/icalendar/tests/test_time.py index 5cd0688c0..8aec78761 100644 --- a/src/icalendar/tests/test_time.py +++ b/src/icalendar/tests/test_time.py @@ -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 @@ -129,5 +129,5 @@ def test_parse_jcal_value_accepts_leap_second(): def test_parse_jcal_value_still_rejects_out_of_range(): """A second beyond the RFC's 00-60 range is rejected.""" - with pytest.raises(Exception): + with pytest.raises(JCalParsingError): vTime.parse_jcal_value("23:59:61") From 8041290a58bfe0b3984440cc1560dfe88e5f42b0 Mon Sep 17 00:00:00 2001 From: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:39:29 +0000 Subject: [PATCH 3/3] Raise JCalParsingError for out-of-range jCal time fields AI-assisted change: drafted with DeepSeek-v4-pro (OpenRouter); prompts summarized, output reviewed and validated against the issue. --- src/icalendar/prop/dt/time.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/icalendar/prop/dt/time.py b/src/icalendar/prop/dt/time.py index eea1d9432..53501c83a 100644 --- a/src/icalendar/prop/dt/time.py +++ b/src/icalendar/prop/dt/time.py @@ -252,16 +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")) - 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) + 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: