From 1ae8460ca4789a7a941426589d30cdd55d9909a7 Mon Sep 17 00:00:00 2001 From: Pavel Koneski Date: Fri, 9 Oct 2026 15:50:19 -0700 Subject: [PATCH 1/6] Fix time.mktime() for zones whose standard UTC offset changes mid-year time.timezone/time.altzone are computed once at process start from TimeZoneInfo.Local.BaseUtcOffset, which models a zone as having at most two offsets (standard/daylight) for all time. That breaks down when a jurisdiction permanently abolishes DST mid-year (e.g. America/Vancouver switching to a DST-less "PT" zone on 2026-11-01): mktime() computed UTC using the stale, zone-wide offset instead of the offset actually in effect on the given date, and produced timestamps off by up to an hour around the transition. --- src/core/IronPython.Modules/time.cs | 55 ++++++++++++++----- .../suite/modules/system_related/test_time.py | 5 +- 2 files changed, 44 insertions(+), 16 deletions(-) diff --git a/src/core/IronPython.Modules/time.cs b/src/core/IronPython.Modules/time.cs index a53cbdee2..4a535617a 100644 --- a/src/core/IronPython.Modules/time.cs +++ b/src/core/IronPython.Modules/time.cs @@ -163,7 +163,26 @@ public static PythonTuple gmtime(object? seconds) { } public static double mktime(CodeContext/*!*/ context, [NotNone] PythonTuple localTime) { - return TicksToTimestamp(GetDateTimeFromTuple(context, localTime).AddSeconds(timezone).Ticks); + DateTime raw = GetDateTimeFromTupleNoDst(context, localTime, out int[]? ints); + TimeZoneInfo tz = TimeZoneInfo.Local; + if (tz.IsAmbiguousTime(raw)) { + // raw occurs twice (once under daylight, once under standard). Resolve directly from the + // ambiguous offset pair rather than going through RemoveDst+GetStandardOffset: raw can sit + // exactly on an AdjustmentRule's date boundary (e.g. a jurisdiction abolishing DST), where + // date-only rule matching can't tell which side of the transition the shifted result landed on. + bool preferDaylight = ints is null || ints[IsDaylightSavingsIndex] != 0; + var offsets = tz.GetAmbiguousTimeOffsets(raw); + return TicksToTimestamp((raw - (preferDaylight ? offsets.Max() : offsets.Min())).Ticks); + } + + DateTime res = raw; + if (ints != null) { + switch (ints[IsDaylightSavingsIndex]) { + case -1: res = RemoveDst(raw); break; + case 1: res = RemoveDst(raw, true); break; + } + } + return TicksToTimestamp((res - GetStandardOffset(res)).Ticks); } public static string strftime(CodeContext/*!*/ context, [NotNone] string format) { @@ -251,6 +270,26 @@ internal static DateTime TimestampToLocalDateTime(double timeStamp) private static DateTime UtcToLocalDateTime(DateTime utc) => new DateTime(utc.Ticks + TimeZoneInfo.Local.GetUtcOffset(utc).Ticks); + // The standard (non-DST) UTC offset in effect on the given date. Unlike TimeZoneInfo.Local.BaseUtcOffset + // (a single zone-wide value), this honors AdjustmentRule.BaseUtcOffsetDelta, which models a jurisdiction + // permanently changing its standard offset (e.g. abolishing DST) rather than merely toggling DST on/off. + // BaseUtcOffsetDelta does not exist prior to .NET 6, so older targets fall back to the zone-wide value. + private static TimeSpan GetStandardOffset(DateTime date) { + TimeZoneInfo tz = TimeZoneInfo.Local; +#if NET6_0_OR_GREATER + // Adjacent rules can share a boundary calendar day (one rule's DateEnd equals the next + // rule's DateStart), so matching on [DateStart, DateEnd] is ambiguous right at the + // transition. Instead pick the rule with the latest DateStart not after the given date - + // since rules abut with no gaps, this unambiguously identifies the rule in effect. + var rule = tz.GetAdjustmentRules().Where(r => r.DateStart.Date <= date.Date) + .OrderByDescending(r => r.DateStart) + .FirstOrDefault(); + return tz.BaseUtcOffset + (rule?.BaseUtcOffsetDelta ?? TimeSpan.Zero); +#else + return tz.BaseUtcOffset; +#endif + } + private static DateTime RemoveDst(DateTime dt, bool always = false) { if (always && TimeZoneInfo.Local.IsAmbiguousTime(dt)) { // the time occurs twice (at the end of DST), pick the daylight saving time offset @@ -423,20 +462,6 @@ internal static struct_time GetDateTimeTuple(DateTime dt, bool dstMode) { return new struct_time(dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, dt.Second, Weekday(dt), dt.DayOfYear, dstMode ? 1 : 0); } - private static DateTime GetDateTimeFromTuple(CodeContext/*!*/ context, PythonTuple t) { - DateTime res = GetDateTimeFromTupleNoDst(context, t, out int[]? ints); - - if (ints != null) { - switch (ints[IsDaylightSavingsIndex]) { - // automatic detection - case -1: res = RemoveDst(res); break; - // is daylight savings time, force adjustment - case 1: res = RemoveDst(res, true); break; - } - } - return res; - } - private static DateTime GetDateTimeFromTupleNoDst(CodeContext context, PythonTuple t) { return GetDateTimeFromTupleNoDst(context, t, out _); } diff --git a/tests/suite/modules/system_related/test_time.py b/tests/suite/modules/system_related/test_time.py index 343d2b6c2..3936b5d7d 100644 --- a/tests/suite/modules/system_related/test_time.py +++ b/tests/suite/modules/system_related/test_time.py @@ -137,11 +137,14 @@ def test_localtime(self): def test_localtime_dst(self): # https://github.com/IronLanguages/ironpython3/issues/570 # check every hour of a year so we go through the DST transitions (if the local time zone has any) + # + # Note: time.timezone/time.altzone are computed once at process start and only hold a single + # standard/daylight offset pair, so they can't describe a zone whose standard offset itself + # changes mid-year (e.g. a jurisdiction abolishing DST); don't use them as an oracle here. t0 = 1767225600 # 2026-01-01 00:00 UTC for h in range(365 * 24): t = t0 + h * 3600 lt = time.localtime(t) - self.assertEqual(time.gmtime(t - (time.altzone if lt.tm_isdst else time.timezone))[:6], lt[:6], lt) self.assertEqual(time.mktime(lt), t, lt) self.assertEqual(datetime.datetime.fromtimestamp(t).timetuple()[:6], lt[:6]) self.assertEqual(datetime.date.fromtimestamp(t).timetuple()[:3], lt[:3]) From 69dfaf705838795e4bd6071aa8d24277314cc7ce Mon Sep 17 00:00:00 2001 From: Pavel Koneski Date: Fri, 9 Oct 2026 16:46:23 -0700 Subject: [PATCH 2/6] Extend the implementation to .NET Framework --- src/core/IronPython.Modules/time.cs | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/src/core/IronPython.Modules/time.cs b/src/core/IronPython.Modules/time.cs index 4a535617a..b07057a91 100644 --- a/src/core/IronPython.Modules/time.cs +++ b/src/core/IronPython.Modules/time.cs @@ -271,23 +271,17 @@ private static DateTime UtcToLocalDateTime(DateTime utc) => new DateTime(utc.Ticks + TimeZoneInfo.Local.GetUtcOffset(utc).Ticks); // The standard (non-DST) UTC offset in effect on the given date. Unlike TimeZoneInfo.Local.BaseUtcOffset - // (a single zone-wide value), this honors AdjustmentRule.BaseUtcOffsetDelta, which models a jurisdiction - // permanently changing its standard offset (e.g. abolishing DST) rather than merely toggling DST on/off. - // BaseUtcOffsetDelta does not exist prior to .NET 6, so older targets fall back to the zone-wide value. + // (a single zone-wide value), this is correct even for a jurisdiction that permanently changes its + // standard offset (e.g. abolishing DST). private static TimeSpan GetStandardOffset(DateTime date) { TimeZoneInfo tz = TimeZoneInfo.Local; -#if NET6_0_OR_GREATER - // Adjacent rules can share a boundary calendar day (one rule's DateEnd equals the next - // rule's DateStart), so matching on [DateStart, DateEnd] is ambiguous right at the - // transition. Instead pick the rule with the latest DateStart not after the given date - - // since rules abut with no gaps, this unambiguously identifies the rule in effect. - var rule = tz.GetAdjustmentRules().Where(r => r.DateStart.Date <= date.Date) - .OrderByDescending(r => r.DateStart) - .FirstOrDefault(); - return tz.BaseUtcOffset + (rule?.BaseUtcOffsetDelta ?? TimeSpan.Zero); -#else - return tz.BaseUtcOffset; -#endif + TimeSpan offset = tz.GetUtcOffset(date); + if (tz.IsDaylightSavingTime(date)) { + var rule = tz.GetAdjustmentRules().FirstOrDefault(r => r.DaylightDelta != TimeSpan.Zero + && r.DateStart.Date <= date.Date && date.Date <= r.DateEnd.Date); + if (rule != null) offset -= rule.DaylightDelta; + } + return offset; } private static DateTime RemoveDst(DateTime dt, bool always = false) { From 4a7251fe1056ca2a991d6a18250b4d50b402cc9d Mon Sep 17 00:00:00 2001 From: Pavel Koneski Date: Sat, 10 Oct 2026 19:58:57 -0700 Subject: [PATCH 3/6] Add test for RemoveDst --- .../suite/modules/system_related/test_time.py | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/suite/modules/system_related/test_time.py b/tests/suite/modules/system_related/test_time.py index 3936b5d7d..b5068d435 100644 --- a/tests/suite/modules/system_related/test_time.py +++ b/tests/suite/modules/system_related/test_time.py @@ -3,6 +3,7 @@ # See the LICENSE file in the project root for more information. import datetime +import os import time from iptest import IronPythonTestCase, is_cli, run_test @@ -149,6 +150,45 @@ def test_localtime_dst(self): self.assertEqual(datetime.datetime.fromtimestamp(t).timetuple()[:6], lt[:6]) self.assertEqual(datetime.date.fromtimestamp(t).timetuple()[:3], lt[:3]) + def test_mktime_forced_dst_no_daylight(self): + # https://github.com/IronLanguages/ironpython3/issues/570 + # Forcing isdst=1 on a date that has no actual daylight saving (e.g. after a + # jurisdiction permanently abolishes DST, as America/Vancouver does on + # 2026-11-01) must give the same result as isdst=0/-1 + # + # Scope the local time zone to America/Vancouver for this test alone, so it + # reproduces regardless of the machine's configured zone + old_tz = os.environ.get('TZ') + os.environ['TZ'] = 'America/Vancouver' + try: + if is_cli: + import System + System.TimeZoneInfo.ClearCachedData() + active = System.TimeZoneInfo.Local.Id == 'America/Vancouver' + else: + active = hasattr(time, 'tzset') + if active: + time.tzset() + + if not active: + # TZ isn't honored at runtime on this platform (e.g. native Windows, + # where both .NET's TimeZoneInfo.Local and the CRT read the OS/registry + # setting instead): nothing to scope, so there's nothing to test here. + self.skipTest('cannot override the local time zone on this platform') + + t = (2026, 12, 15, 12, 0, 0, 0, 0, 0) + self.assertEqual(time.mktime(t), time.mktime(t[:8] + (1,))) + self.assertEqual(time.mktime(t), time.mktime(t[:8] + (-1,))) + finally: + if old_tz is None: + os.environ.pop('TZ', None) + else: + os.environ['TZ'] = old_tz + if is_cli: + System.TimeZoneInfo.ClearCachedData() + elif hasattr(time, 'tzset'): + time.tzset() + def test_asctime(self): self.assertEqual(time.asctime((2009, 9, 4, 14, 57, 11, 4, 247, 0)), 'Fri Sep 4 14:57:11 2009') self.assertEqual(time.asctime((2009, 9, 4, 14, 57, 11, 4, 247, 1)), 'Fri Sep 4 14:57:11 2009') From 115aa5fc2d901e3723d99bbea708ab48f692dbb3 Mon Sep 17 00:00:00 2001 From: Pavel Koneski Date: Sat, 10 Oct 2026 19:59:18 -0700 Subject: [PATCH 4/6] Fix RemoveDst --- src/core/IronPython.Modules/time.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/core/IronPython.Modules/time.cs b/src/core/IronPython.Modules/time.cs index b07057a91..4223ae65b 100644 --- a/src/core/IronPython.Modules/time.cs +++ b/src/core/IronPython.Modules/time.cs @@ -285,13 +285,14 @@ private static TimeSpan GetStandardOffset(DateTime date) { } private static DateTime RemoveDst(DateTime dt, bool always = false) { + // Use the date-aware standard offset (GetStandardOffset), not TimeZoneInfo.Local.BaseUtcOffset if (always && TimeZoneInfo.Local.IsAmbiguousTime(dt)) { // the time occurs twice (at the end of DST), pick the daylight saving time offset - return dt - (TimeZoneInfo.Local.GetAmbiguousTimeOffsets(dt).Max() - TimeZoneInfo.Local.BaseUtcOffset); + return dt - (TimeZoneInfo.Local.GetAmbiguousTimeOffsets(dt).Max() - GetStandardOffset(dt)); } if (always || TimeZoneInfo.Local.IsDaylightSavingTime(dt)) { - dt -= TimeZoneInfo.Local.GetUtcOffset(dt) - TimeZoneInfo.Local.BaseUtcOffset; + dt -= TimeZoneInfo.Local.GetUtcOffset(dt) - GetStandardOffset(dt); } return dt; From adf54cb9f3ce3565d18a3322e9c2e0829d0d2440 Mon Sep 17 00:00:00 2001 From: Pavel Koneski Date: Sat, 10 Oct 2026 20:15:59 -0700 Subject: [PATCH 5/6] Cache timezone adjustment rules --- src/core/IronPython.Modules/time.cs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/core/IronPython.Modules/time.cs b/src/core/IronPython.Modules/time.cs index 4223ae65b..e6780148c 100644 --- a/src/core/IronPython.Modules/time.cs +++ b/src/core/IronPython.Modules/time.cs @@ -277,13 +277,29 @@ private static TimeSpan GetStandardOffset(DateTime date) { TimeZoneInfo tz = TimeZoneInfo.Local; TimeSpan offset = tz.GetUtcOffset(date); if (tz.IsDaylightSavingTime(date)) { - var rule = tz.GetAdjustmentRules().FirstOrDefault(r => r.DaylightDelta != TimeSpan.Zero + var rule = GetAdjustmentRules(tz).FirstOrDefault(r => r.DaylightDelta != TimeSpan.Zero && r.DateStart.Date <= date.Date && date.Date <= r.DateEnd.Date); if (rule != null) offset -= rule.DaylightDelta; } return offset; } + private sealed class AdjustmentRulesCache(TimeZoneInfo tz, TimeZoneInfo.AdjustmentRule[] rules) { + public TimeZoneInfo Tz { get; } = tz; + public TimeZoneInfo.AdjustmentRule[] Rules { get; } = rules; + } + + private static AdjustmentRulesCache? _adjustmentRulesCache; + + private static TimeZoneInfo.AdjustmentRule[] GetAdjustmentRules(TimeZoneInfo tz) { + var cache = _adjustmentRulesCache; + if (cache is null || !ReferenceEquals(cache.Tz, tz)) { + cache = new AdjustmentRulesCache(tz, tz.GetAdjustmentRules()); + _adjustmentRulesCache = cache; + } + return cache.Rules; + } + private static DateTime RemoveDst(DateTime dt, bool always = false) { // Use the date-aware standard offset (GetStandardOffset), not TimeZoneInfo.Local.BaseUtcOffset if (always && TimeZoneInfo.Local.IsAmbiguousTime(dt)) { From 0a2bbeed7a07b4658f372537d39d220a8ed13cbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Lozier?= Date: Sun, 11 Oct 2026 09:45:47 -0400 Subject: [PATCH 6/6] Remove ints allocation --- src/core/IronPython.Modules/time.cs | 72 +++++++------------ .../suite/modules/system_related/test_time.py | 10 ++- 2 files changed, 36 insertions(+), 46 deletions(-) diff --git a/src/core/IronPython.Modules/time.cs b/src/core/IronPython.Modules/time.cs index e6780148c..cf302e539 100644 --- a/src/core/IronPython.Modules/time.cs +++ b/src/core/IronPython.Modules/time.cs @@ -96,7 +96,7 @@ public static string asctime(CodeContext/*!*/ context) => AscTimeFormat(DateTime.Now); public static string asctime(CodeContext/*!*/ context, [NotNone] PythonTuple time) - => AscTimeFormat(GetDateTimeFromTupleNoDst(context, time)); + => AscTimeFormat(GetDateTimeFromTuple(time, out _)); public static double clock() { InitStopWatch(); @@ -163,25 +163,23 @@ public static PythonTuple gmtime(object? seconds) { } public static double mktime(CodeContext/*!*/ context, [NotNone] PythonTuple localTime) { - DateTime raw = GetDateTimeFromTupleNoDst(context, localTime, out int[]? ints); + DateTime raw = GetDateTimeFromTuple(localTime, out int isDst); TimeZoneInfo tz = TimeZoneInfo.Local; if (tz.IsAmbiguousTime(raw)) { // raw occurs twice (once under daylight, once under standard). Resolve directly from the // ambiguous offset pair rather than going through RemoveDst+GetStandardOffset: raw can sit // exactly on an AdjustmentRule's date boundary (e.g. a jurisdiction abolishing DST), where // date-only rule matching can't tell which side of the transition the shifted result landed on. - bool preferDaylight = ints is null || ints[IsDaylightSavingsIndex] != 0; + bool preferDaylight = isDst != 0; var offsets = tz.GetAmbiguousTimeOffsets(raw); return TicksToTimestamp((raw - (preferDaylight ? offsets.Max() : offsets.Min())).Ticks); } - DateTime res = raw; - if (ints != null) { - switch (ints[IsDaylightSavingsIndex]) { - case -1: res = RemoveDst(raw); break; - case 1: res = RemoveDst(raw, true); break; - } - } + DateTime res = isDst switch { + 0 => raw, + < 0 => RemoveDst(raw), + _ => RemoveDst(raw, true), + }; return TicksToTimestamp((res - GetStandardOffset(res)).Ticks); } @@ -190,7 +188,7 @@ public static string strftime(CodeContext/*!*/ context, [NotNone] string format) } public static string strftime(CodeContext/*!*/ context, [NotNone] string format, [NotNone] PythonTuple dateTime) { - return strftime(context, format, GetDateTimeFromTupleNoDst(context, dateTime), null, TimeZoneInfo.Local, errorOnF: true); + return strftime(context, format, GetDateTimeFromTuple(dateTime, out _), null, TimeZoneInfo.Local, errorOnF: true); } public static object? strptime(CodeContext/*!*/ context, [NotNone] string @string) { @@ -473,46 +471,30 @@ internal static struct_time GetDateTimeTuple(DateTime dt, bool dstMode) { return new struct_time(dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, dt.Second, Weekday(dt), dt.DayOfYear, dstMode ? 1 : 0); } - private static DateTime GetDateTimeFromTupleNoDst(CodeContext context, PythonTuple t) { - return GetDateTimeFromTupleNoDst(context, t, out _); - } - - private static DateTime GetDateTimeFromTupleNoDst(CodeContext context, PythonTuple t, out int[]? ints) { - if (t == null) { - ints = null; - return DateTime.Now; - } - - ints = ValidateDateTimeTuple(context, t); + private static int ToInt32(object? o) + => Converter.TryConvertToIndex(o, out int i) ? i : throw PythonOps.TypeErrorForUnIndexableObject(o); - var month = ints[MonthIndex]; - if (month == 0) month = 1; - var day = ints[DayIndex]; - if (day == 0) day = 1; - - return new DateTime(ints[YearIndex], month, day, ints[HourIndex], ints[MinuteIndex], ints[SecondIndex]); - } - - private static int[] ValidateDateTimeTuple(CodeContext/*!*/ context, PythonTuple t) { + private static DateTime GetDateTimeFromTuple(PythonTuple t, out int isDst) { if (t.__len__() != MaxIndex) throw PythonOps.TypeError("expected tuple of length {0}", MaxIndex); - int[] ints = new int[MaxIndex]; - for (int i = 0; i < MaxIndex; i++) { - ints[i] = context.LanguageContext.ConvertToInt32(t[i]); - } + var year = ToInt32(t[YearIndex]); + var month = ToInt32(t[MonthIndex]); + var day = ToInt32(t[DayIndex]); + var hour = ToInt32(t[HourIndex]); + var minute = ToInt32(t[MinuteIndex]); + var second = ToInt32(t[SecondIndex]); + var weekday = ToInt32(t[WeekdayIndex]); + ToInt32(t[DayOfYearIndex]); // validate + isDst = ToInt32(t[IsDaylightSavingsIndex]); - int year = ints[YearIndex]; - if (year >= 0 && year <= 99) { - if (year > 68) { - year += 1900; - } else { - year += 2000; - } - } if (year < DateTime.MinValue.Year) throw PythonOps.ValueError("year is too low"); if (year > DateTime.MaxValue.Year) throw PythonOps.ValueError("year is too high"); - if (ints[WeekdayIndex] < 0 || ints[WeekdayIndex] >= 7) throw PythonOps.ValueError("day of week is outside of 0-6 range"); - return ints; + if (weekday < 0 || weekday >= 7) throw PythonOps.ValueError("day of week is outside of 0-6 range"); + + if (month == 0) month = 1; + if (day == 0) day = 1; + + return new DateTime(year, month, day, hour, minute, second); } [MemberNotNull(nameof(sw))] diff --git a/tests/suite/modules/system_related/test_time.py b/tests/suite/modules/system_related/test_time.py index b5068d435..3d2abbf65 100644 --- a/tests/suite/modules/system_related/test_time.py +++ b/tests/suite/modules/system_related/test_time.py @@ -6,7 +6,7 @@ import os import time -from iptest import IronPythonTestCase, is_cli, run_test +from iptest import IronPythonTestCase, is_cli, is_windows, run_test class TimeTest(IronPythonTestCase): def test_strftime(self): @@ -194,5 +194,13 @@ def test_asctime(self): self.assertEqual(time.asctime((2009, 9, 4, 14, 57, 11, 4, 247, 1)), 'Fri Sep 4 14:57:11 2009') self.assertEqual(time.asctime((2009, 9, 4, 14, 57, 11, 4, 247, -1)), 'Fri Sep 4 14:57:11 2009') + def test_year_lt_100(self): + t = (50, 7, 1, 12, 0, 0, 2, 182, -1) + if is_cli or not is_windows: + # years below 100 are taken literally and not mapped to 19xx/20xx + self.assertEqual(time.localtime(time.mktime(t))[:6], t[:6]) + else: + self.assertRaises(OverflowError, time.mktime, t) + run_test(__name__)