From 620bdf53910968a99ef475f5bddcbe55719758f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Lozier?= Date: Tue, 6 Oct 2026 22:58:41 -0400 Subject: [PATCH 1/2] Fix time.localtime and datetime.fromtimestamp around DST transitions --- src/core/IronPython.Modules/_datetime.cs | 16 ++----- src/core/IronPython.Modules/time.cs | 42 +++++++++---------- tests/suite/modules/misc/test_datetime.py | 1 + .../suite/modules/system_related/test_time.py | 13 ++++++ 4 files changed, 38 insertions(+), 34 deletions(-) diff --git a/src/core/IronPython.Modules/_datetime.cs b/src/core/IronPython.Modules/_datetime.cs index 5d9867e18..bd57f73ac 100644 --- a/src/core/IronPython.Modules/_datetime.cs +++ b/src/core/IronPython.Modules/_datetime.cs @@ -416,9 +416,7 @@ public static date fromordinal(int d) { } public static date fromtimestamp(double timestamp) { - DateTime dt = PythonTime.TimestampToDateTime(timestamp); - dt = dt.AddSeconds(-PythonTime.timezone); - + DateTime dt = PythonTime.TimestampToLocalDateTime(timestamp); return new date(dt.Year, dt.Month, dt.Day); } @@ -762,21 +760,15 @@ public static object utcnow() { } public static object fromtimestamp(double timestamp, tzinfo? tz = null) { - DateTime dt = PythonTime.TimestampToDateTime(timestamp); - dt = dt.AddSeconds(-PythonTime.timezone); - if (tz != null) { - dt = dt.ToUniversalTime(); - datetime pdtc = new datetime(dt, tz); - return tz.fromutc(pdtc); + return tz.fromutc(new datetime(PythonTime.TimestampToUtcDateTime(timestamp), tz)); } else { - return new datetime(dt); + return new datetime(PythonTime.TimestampToLocalDateTime(timestamp)); } } public static datetime utcfromtimestamp(double timestamp) { - DateTime dt = new DateTime(PythonTime.TimestampToTicks(timestamp), DateTimeKind.Utc); - return new datetime(dt, 0, null); + return new datetime(PythonTime.TimestampToUtcDateTime(timestamp), null); } public static new datetime fromordinal(int d) { diff --git a/src/core/IronPython.Modules/time.cs b/src/core/IronPython.Modules/time.cs index f98f2c457..a53cbdee2 100644 --- a/src/core/IronPython.Modules/time.cs +++ b/src/core/IronPython.Modules/time.cs @@ -144,21 +144,21 @@ public static BigInteger monotonic_ns() => (BigInteger)Stopwatch.GetTimestamp() * 1000000000 / Stopwatch.Frequency; public static double time() - => TicksToTimestamp(DateTime.Now.ToUniversalTime().Ticks); + => TicksToTimestamp(DateTime.UtcNow.Ticks); public static PythonTuple localtime() => localtime(null); public static PythonTuple localtime(object? seconds) { - DateTime dt = seconds is null ? DateTime.Now : TimestampToDateTime(GetTimestampFromObject(seconds)).AddSeconds(-timezone); - return GetDateTimeTuple(dt, dt.IsDaylightSavingTime()); + DateTime utc = seconds is null ? DateTime.UtcNow : TimestampToUtcDateTime(GetTimestampFromObject(seconds)); + return GetDateTimeTuple(UtcToLocalDateTime(utc), TimeZoneInfo.Local.IsDaylightSavingTime(utc)); } public static PythonTuple gmtime() => gmtime(null); public static PythonTuple gmtime(object? seconds) { - DateTime dt = seconds is null ? DateTime.Now.ToUniversalTime() : new DateTime(TimestampToTicks(GetTimestampFromObject(seconds)), DateTimeKind.Unspecified); + DateTime dt = seconds is null ? DateTime.UtcNow : TimestampToUtcDateTime(GetTimestampFromObject(seconds)); return GetDateTimeTuple(dt, false); } @@ -238,29 +238,27 @@ internal static string strftime(CodeContext/*!*/ context, string format, DateTim return res.ToString(); } - internal static double DateTimeToTimestamp(DateTime dateTime) { - return TicksToTimestamp(RemoveDst(dateTime).Ticks); - } + internal static DateTime TimestampToUtcDateTime(double timeStamp) + => new DateTime(TimestampToTicks(timeStamp), DateTimeKind.Utc); - internal static DateTime TimestampToDateTime(double timeStamp) { - return AddDst(new DateTime(TimestampToTicks(timeStamp))); - } + /// + /// Converts a POSIX timestamp to the local time. + /// + internal static DateTime TimestampToLocalDateTime(double timeStamp) + => UtcToLocalDateTime(TimestampToUtcDateTime(timeStamp)); - private static DateTime RemoveDst(DateTime dt) { - return RemoveDst(dt, false); - } + // unlike TimeZoneInfo.ConvertTimeFromUtc, this throws instead of clamping when the result is out of range + private static DateTime UtcToLocalDateTime(DateTime utc) + => new DateTime(utc.Ticks + TimeZoneInfo.Local.GetUtcOffset(utc).Ticks); - private static DateTime RemoveDst(DateTime dt, bool always) { - if (always || TimeZoneInfo.Local.IsDaylightSavingTime(dt)) { - dt -= TimeZoneInfo.Local.GetUtcOffset(dt) - TimeZoneInfo.Local.BaseUtcOffset; + 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 + return dt - (TimeZoneInfo.Local.GetAmbiguousTimeOffsets(dt).Max() - TimeZoneInfo.Local.BaseUtcOffset); } - return dt; - } - - private static DateTime AddDst(DateTime dt) { - if (TimeZoneInfo.Local.IsDaylightSavingTime(dt)) { - dt += TimeZoneInfo.Local.GetUtcOffset(dt) - TimeZoneInfo.Local.BaseUtcOffset; + if (always || TimeZoneInfo.Local.IsDaylightSavingTime(dt)) { + dt -= TimeZoneInfo.Local.GetUtcOffset(dt) - TimeZoneInfo.Local.BaseUtcOffset; } return dt; diff --git a/tests/suite/modules/misc/test_datetime.py b/tests/suite/modules/misc/test_datetime.py index 3b248a377..326d8f351 100644 --- a/tests/suite/modules/misc/test_datetime.py +++ b/tests/suite/modules/misc/test_datetime.py @@ -443,6 +443,7 @@ def test_datetime(self): self.assertEqual(x.hour, 1) self.assertEqual(x.minute, 46) self.assertEqual(x.second, 40) + self.assertEqual(datetime.datetime.utcfromtimestamp(0.000123).microsecond, 123) #combine x = datetime.datetime.combine(datetime.date(2005, 3, 22), datetime.time(2,28,3,99)) diff --git a/tests/suite/modules/system_related/test_time.py b/tests/suite/modules/system_related/test_time.py index 213188dac..343d2b6c2 100644 --- a/tests/suite/modules/system_related/test_time.py +++ b/tests/suite/modules/system_related/test_time.py @@ -2,6 +2,7 @@ # The .NET Foundation licenses this file to you under the Apache 2.0 License. # See the LICENSE file in the project root for more information. +import datetime import time from iptest import IronPythonTestCase, is_cli, run_test @@ -133,6 +134,18 @@ def test_gmtime(self): def test_localtime(self): self.assertEqual(time.mktime(time.localtime(0)), 0) + 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) + 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]) + 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 50d9a2b4a20f262725e7907f37da50f22f197125 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Lozier?= Date: Wed, 7 Oct 2026 10:43:09 -0400 Subject: [PATCH 2/2] Fix failing test --- tests/suite/stdlib/test_datetime.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/suite/stdlib/test_datetime.py b/tests/suite/stdlib/test_datetime.py index 54742c183..dd0d0bb88 100644 --- a/tests/suite/stdlib/test_datetime.py +++ b/tests/suite/stdlib/test_datetime.py @@ -22,7 +22,6 @@ def load_tests(loader, standard_tests, pattern): test.datetimetester.TestDateTime('test_extreme_timedelta'), test.datetimetester.TestDateTime('test_insane_fromtimestamp'), test.datetimetester.TestDateTime('test_insane_utcfromtimestamp'), - test.datetimetester.TestDateTime('test_microsecond_rounding'), test.datetimetester.TestDateTime('test_strftime_with_bad_tzname_replace'), test.datetimetester.TestDateTimeTZ('test_backdoor_resistance'), test.datetimetester.TestDateTimeTZ('test_even_more_compare'), @@ -30,7 +29,6 @@ def load_tests(loader, standard_tests, pattern): test.datetimetester.TestDateTimeTZ('test_extreme_timedelta'), test.datetimetester.TestDateTimeTZ('test_insane_fromtimestamp'), test.datetimetester.TestDateTimeTZ('test_insane_utcfromtimestamp'), - test.datetimetester.TestDateTimeTZ('test_microsecond_rounding'), test.datetimetester.TestDateTimeTZ('test_mixed_compare'), test.datetimetester.TestDateTimeTZ('test_strftime_with_bad_tzname_replace'), test.datetimetester.TestDateTimeTZ('test_tz_aware_arithmetic'), @@ -38,7 +36,6 @@ def load_tests(loader, standard_tests, pattern): test.datetimetester.TestSubclassDateTime('test_extreme_timedelta'), test.datetimetester.TestSubclassDateTime('test_insane_fromtimestamp'), test.datetimetester.TestSubclassDateTime('test_insane_utcfromtimestamp'), - test.datetimetester.TestSubclassDateTime('test_microsecond_rounding'), test.datetimetester.TestSubclassDateTime('test_replace'), # TODO test.datetimetester.TestSubclassDateTime('test_strftime_with_bad_tzname_replace'), test.datetimetester.TestTimeDelta('test_computations'), # rounding differences