From 1f4242a789a5ee65e7215e971a45276f26b2a2bb Mon Sep 17 00:00:00 2001 From: Liang Hu <35699841+LarryHu0217@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:59:45 -0400 Subject: [PATCH] gh-160433: Correct timed log backup names across DST changes (GH-160564) (cherry picked from commit 457ba33e96e644b402ea83bd6079af264b192ffb) Co-authored-by: Liang Hu <35699841+LarryHu0217@users.noreply.github.com> --- Lib/logging/handlers.py | 19 ++--- Lib/test/test_logging.py | 71 +++++++++++++++++++ ...-10-24-01-53-42.gh-issue-160433.DSTlog.rst | 2 + 3 files changed, 84 insertions(+), 8 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-10-24-01-53-42.gh-issue-160433.DSTlog.rst diff --git a/Lib/logging/handlers.py b/Lib/logging/handlers.py index 0a787f4d3a93a7..7c1fdfad1d9790 100644 --- a/Lib/logging/handlers.py +++ b/Lib/logging/handlers.py @@ -436,14 +436,17 @@ def doRollover(self): timeTuple = time.gmtime(t) else: timeTuple = time.localtime(t) - dstNow = time.localtime(currentTime)[-1] - dstThen = timeTuple[-1] - if dstNow != dstThen: - if dstNow: - addend = 3600 - else: - addend = -3600 - timeTuple = time.localtime(t + addend) + # DST affects calendar-based intervals, not fixed-duration ones. + if self.when == 'MIDNIGHT' or self.when.startswith('W'): + # A record can arrive well after the scheduled rollover. + dstAtRollover = time.localtime(self.rolloverAt)[-1] + dstThen = timeTuple[-1] + if dstAtRollover != dstThen: + if dstAtRollover: + addend = 3600 + else: + addend = -3600 + timeTuple = time.localtime(t + addend) dfn = self.rotation_filename(self.baseFilename + "." + time.strftime(self.suffix, timeTuple)) self.rolloverAt = self.computeRollover(currentTime) diff --git a/Lib/test/test_logging.py b/Lib/test/test_logging.py index 4b3473835b133f..3f73cf84d1ca70 100644 --- a/Lib/test/test_logging.py +++ b/Lib/test/test_logging.py @@ -6584,6 +6584,77 @@ def rotator(source, dest): rh.close() class TimedRotatingFileHandlerTest(BaseFileTest): + def assert_rollover_contents(self, when, timestamps, expected, **kwargs): + with tempfile.TemporaryDirectory() as dirname: + filename = os.path.join(dirname, 'app.log') + now = [timestamps[0]] + with patch('time.time', lambda: now[0]): + handler = logging.handlers.TimedRotatingFileHandler( + filename, when=when, delay=True, encoding='utf-8', **kwargs) + try: + for i, timestamp in enumerate(timestamps): + now[0] = timestamp + handler.emit(logging.makeLogRecord({'msg': str(i)})) + finally: + handler.close() + actual = {} + for name in os.listdir(dirname): + with open(os.path.join(dirname, name), encoding='utf-8') as f: + actual[name.removeprefix('app.log')] = f.read().splitlines() + self.assertEqual(actual, expected) + + @support.run_with_tz('EST+05EDT,M3.2.0,M11.1.0') + def test_rollover_fixed_interval_dst(self): + DT = datetime.datetime + cases = ( + ('H', DT(2026, 3, 8, 1, 30), 3), + ('H', DT(2026, 11, 1, 1, 30), 2), + ('D', DT(2026, 3, 7, 23, 30), 3), + ('D', DT(2026, 11, 1, 0, 30), 2), + ) + for when, start, count in cases: + interval = 3600 if when == 'H' else 86400 + timestamps = [start.timestamp() + i * interval for i in range(count)] + suffix = '.%Y-%m-%d_%H' if when == 'H' else '.%Y-%m-%d' + for utc in (False, True): + with self.subTest(when=when, start=start, utc=utc): + convert = time.gmtime if utc else time.localtime + expected = { + time.strftime(suffix, convert(timestamp)): [str(i)] + for i, timestamp in enumerate(timestamps[:-1]) + } + expected[''] = [str(count - 1)] + self.assert_rollover_contents( + when, timestamps, expected, utc=utc) + + @support.run_with_tz('EST+05EDT,M3.2.0,M11.1.0') + def test_rollover_delayed_dst(self): + DT = datetime.datetime + cases = ( + ('MIDNIGHT', None, + [DT(2026, 10, day, hour).timestamp() + for day in (30, 31) for hour in (9, 18)] + + [DT(2026, 11, day, hour).timestamp() + for day in (1, 2, 3) for hour in (9, 18)], + {'.2026-10-30': ['0', '1'], '.2026-10-31': ['2', '3'], + '.2026-11-01': ['4', '5'], '.2026-11-02': ['6', '7'], + '': ['8', '9']}), + ('W6', None, + [DT(2026, 10, 23, 9).timestamp(), + DT(2026, 11, 1, 9).timestamp(), + DT(2026, 11, 2, 9).timestamp()], + {'.2026-10-19': ['0'], '.2026-10-26': ['1'], '': ['2']}), + ('MIDNIGHT', datetime.time(23, 30), + [DT(2026, month, 1, 12).timestamp() for month in (3, 4, 10, 12)], + {'.2026-02-28': ['0'], '.2026-03-31': ['1'], + '.2026-09-30': ['2'], '': ['3']}), + ) + for when, at_time, timestamps, expected in cases: + for utc in (False, True): + with self.subTest(when=when, atTime=at_time, utc=utc): + self.assert_rollover_contents( + when, timestamps, expected, atTime=at_time, utc=utc) + @unittest.skipIf(support.is_wasi, "WASI does not have /dev/null.") def test_should_not_rollover(self): # See bpo-45401. Should only ever rollover regular files diff --git a/Misc/NEWS.d/next/Library/2026-10-24-01-53-42.gh-issue-160433.DSTlog.rst b/Misc/NEWS.d/next/Library/2026-10-24-01-53-42.gh-issue-160433.DSTlog.rst new file mode 100644 index 00000000000000..d6779cf60249d0 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-24-01-53-42.gh-issue-160433.DSTlog.rst @@ -0,0 +1,2 @@ +Fix incorrect backup names around daylight saving time changes in +:class:`logging.handlers.TimedRotatingFileHandler`, including delayed rollovers.