为什么TimedRotatingFileHandler不会删除旧文件?

8

我正在使用TimedRotatingFileHandler来创建我的日志文件。 我希望每分钟创建一个日志文件,最多保留2个日志文件并删除旧的日志文件。以下是示例代码:

import logging
import logging.handlers
import datetime

logger = logging.getLogger('MyLogger')
logger.setLevel(logging.DEBUG)

handler = logging.handlers.TimedRotatingFileHandler(
    "logs/{:%H-%M}.log".format(datetime.datetime.now()), 
    when="M", 
    backupCount=2)

logger.addHandler(handler)
logger.debug("PLEASE DELETE PREVIOUS FILES")

如果我多次运行此代码(间隔一分钟),则会在我的日志目录中获得多个文件,如下所示:
21-01.log
21-02.log
21-03.log
...

这对我来说似乎很奇怪,因为我设置了backupCount=2,这表示最多应保存2个文件,并且应删除旧文件。但是,当我启动我的应用程序并且日志文件夹中有2个或更多文件时,旧文件没有被删除。

为什么TimedRotatingFileHandler不删除旧文件?我可以设置TimedRotatingFileHandler以删除旧文件吗?


你解决过这个问题吗?我也遇到了同样的问题。 - eran
看起来 logging/config.py 中的 _install_handlers 函数没有读取那个 backupCount 部分,因此构造函数始终为 TimedRotatingFileHandlerbackupCount=0。似乎是个 bug。问题是如何解决这个问题,而不必等待主分支上的问题。 - eran
@eran 不确定是否符合您的问题,但这里的问题是日志文件名称。当您定义一个固定的日志名称时,日志文件滚动会发生。例如,TimedRotatingFileHandler('my.log', when='s', backupCount=2) 每秒钟会旋转一次日志,因此您将获得 my.log 和最后两个备份(文件名为 my.log.YYYY-MM-DD_HH-MM-SS)。OP 的代码每次都创建具有新名称的日志文件,因此不会发生滚动,并且备份计数永远不会达到。 - hoefling
@hoefling 您是正确的。这不完全是我的问题,因为最初提出问题的人似乎是自己将日期添加到文件名中的。 当我提供赏金时,我有点忽略了这一点。 然而,问题仍然存在。我发现.ini文件解析器中存在一个错误。它不会加载backupCount参数,因此TimedRotatingFileHandler的构造函数得到backupCount=0。当我进行手术旁路时,旋转删除起作用了。我正在寻找一个非原始包补丁解决方案。 - eran
4个回答

10

如您在TimedRotatingFileHandler文档中所看到的那样,为了使轮换系统正常工作,您的日志文件名应该保持一致。

在您的情况下,因为您自己添加了日期时间信息,每次日志文件名都是不同的,这就导致了您观察到的结果。

因此,在您的源代码中,您只需要调整日志文件名即可:

handler = logging.handlers.TimedRotatingFileHandler(
    "logs/MyLog", 
    when="M", 
    backupCount=2)

如果您想挑战它,您可以将 when 更改为 "S"(秒),并检查旋转是否正常。

例如,它会自动产生这样的文件:

> MyLog
> MyLog.2019-07-08_11-36-53
> MyLog.2019-07-08_11-36-58

如果您需要额外的信息,请不要犹豫。


谢谢@Bsquare。我犯了一个错误,因为我的问题并不完全与原始问题相同。我正在使用一个常量文件名。但是,我发现.ini文件解析器中存在一个错误。它不会加载backupCount参数,因此TimedRotatingFileHandler的构造函数得到backupCount=0。当我进行手术旁路时,旋转删除起作用了。我正在寻找一个非原始包补丁解决方案。由于您回答了原始问题-我将授予赏金-公平起见。如果您碰巧也能帮助解决我的问题,我将不胜感激。 - eran
谢谢你的公平。当然,如果我能帮忙的话,我会的。现在,你的问题是备份计数参数没有得到处理,除非修改模块,对吗? - Bsquare ℬℬ
没错,这就是问题所在。调试后发现,参数backupCount并没有从ini文件中读取。除了向原始项目提出问题(这需要时间),我可以扩展TimedRotationFileHandler来解决这个问题吗?不确定如何扩展新的处理程序并添加要从ini文件中读取的参数。 - eran
好的。在我的本地测试环境中似乎可以工作。你能提供你的确切源代码吗? - Bsquare ℬℬ

6
你无法使用TimedRotatingFileHandler,原因是该处理程序期望“当前”日志文件名保持稳定,并将旋转定义为通过重命名将现有日志文件移动到备份中。这些备份将被保留或删除。旋转备份是从基本文件名加上带有旋转时间戳的后缀创建的。因此,实现区分日志文件(存储在baseFilename中)和旋转文件(在doRotate()方法中生成)。请注意,仅当发生旋转时才会删除备份,因此处理程序已经使用至少一个完整间隔。

相反,你需要让基本文件名本身携带时间信息,因此正在变化日志文件名本身。在这种情况下,没有“备份”,你只需在旋转时打开一个新文件。此外,你似乎正在运行短暂的 Python 代码,因此要立即删除较旧的文件,而不仅仅是在显式旋转时删除,这可能永远不会到达。

这就是为什么TimedRotatingFileHandler不会删除任何文件,因为*它永远不会创建备份文件。没有备份意味着没有要删除的备份。为了旋转文件,处理程序的当前实现期望负责文件名生成,并且不能指望知道它本身不会生成的文件名。当你使用每分钟旋转频率的"M"配置它时,它被配置为将文件旋转到备份文件中,该模式为{baseFileame}.{now:%Y-%m-%d_%H_%M},因此只会删除与该模式匹配的旋转备份文件。请参见文档

系统将通过向文件名附加扩展名来保存旧的日志文件。扩展名基于日期和时间,使用strftime格式%Y-%m-%d_%H-%M-%S或其前面的部分,具体取决于翻转间隔。

相反,你想要的是一个基本文件名本身携带时间戳,并且在打开具有不同名称的新日志文件时删除较旧的日志文件(而不是备份文件)。为此,你需要创建一个自定义处理程序。

幸运的是,类层次结构专门设计为易于定制。你可以在这里子类化BaseRotatingHandler并提供自己的删除逻辑:

import os
import time
from itertools import islice
from logging.handlers import BaseRotatingHandler, TimedRotatingFileHandler

# rotation intervals in seconds
_intervals = {
    "S": 1,
    "M": 60,
    "H": 60 * 60,
    "D": 60 * 60 * 24,
    "MIDNIGHT": 60 * 60 * 24,
    "W": 60 * 60 * 24 * 7,
}

class TimedPatternFileHandler(BaseRotatingHandler):
    """File handler that uses the current time in the log filename.

    The time is quantisized to a configured interval. See
    TimedRotatingFileHandler for the meaning of the when, interval, utc and
    atTime arguments.

    If backupCount is non-zero, then older filenames that match the base
    filename are deleted to only leave the backupCount most recent copies,
    whenever opening a new log file with a different name.

    """

    def __init__(
        self,
        filenamePattern,
        when="h",
        interval=1,
        backupCount=0,
        encoding=None,
        delay=False,
        utc=False,
        atTime=None,
    ):
        self.when = when.upper()
        self.backupCount = backupCount
        self.utc = utc
        self.atTime = atTime
        try:
            key = "W" if self.when.startswith("W") else self.when
            self.interval = _intervals[key]
        except KeyError:
            raise ValueError(
                f"Invalid rollover interval specified: {self.when}"
            ) from None
        if self.when.startswith("W"):
            if len(self.when) != 2:
                raise ValueError(
                    "You must specify a day for weekly rollover from 0 to 6 "
                    f"(0 is Monday): {self.when}"
                )
            if not "0" <= self.when[1] <= "6":
                raise ValueError(
                    f"Invalid day specified for weekly rollover: {self.when}"
                )
            self.dayOfWeek = int(self.when[1])

        self.interval = self.interval * interval
        self.pattern = os.path.abspath(os.fspath(filenamePattern))

        # determine best time to base our rollover times on
        # prefer the creation time of the most recently created log file.
        t = now = time.time()
        entry = next(self._matching_files(), None)
        if entry is not None:
            t = entry.stat().st_ctime
            while t + self.interval < now:
                t += self.interval

        self.rolloverAt = self.computeRollover(t)

        # delete older files on startup and not delaying
        if not delay and backupCount > 0:
            keep = backupCount
            if os.path.exists(self.baseFilename):
                keep += 1
                delete = islice(self._matching_files(), keep, None)
                for entry in delete:
                    os.remove(entry.path)

        # Will set self.baseFilename indirectly, and then may use
        # self.baseFilename to open. So by this point self.rolloverAt and
        # self.interval must be known.
        super().__init__(filenamePattern, "a", encoding, delay)

    @property
    def baseFilename(self):
        """Generate the 'current' filename to open"""
        # use the start of *this* interval, not the next
        t = self.rolloverAt - self.interval
        if self.utc:
            time_tuple = time.gmtime(t)
        else:
            time_tuple = time.localtime(t)
            dst = time.localtime(self.rolloverAt)[-1]
            if dst != time_tuple[-1] and self.interval > 3600:
                # DST switches between t and self.rolloverAt, adjust
                addend = 3600 if dst else -3600
                time_tuple = time.localtime(t + addend)
        return time.strftime(self.pattern, time_tuple)

    @baseFilename.setter
    def baseFilename(self, _):
        # assigned to by FileHandler, just ignore this as we use self.pattern
        # instead
        pass

    def _matching_files(self):
        """Generate DirEntry entries that match the filename pattern.

        The files are ordered by their last modification time, most recent
        files first.

        """
        matches = []
        pattern = self.pattern
        for entry in os.scandir(os.path.dirname(pattern)):
            if not entry.is_file():
                continue
            try:
                time.strptime(entry.path, pattern)
                matches.append(entry)
            except ValueError:
                continue
        matches.sort(key=lambda e: e.stat().st_mtime, reverse=True)
        return iter(matches)

    def doRollover(self):
        """Do a roll-over. This basically needs to open a new generated filename.
        """
        if self.stream:
            self.stream.close()
            self.stream = None

        if self.backupCount > 0:
            delete = islice(self._matching_files(), self.backupCount, None)
            for entry in delete:
                os.remove(entry.path)

        now = int(time.time())
        rollover = self.computeRollover(now)
        while rollover <= now:
            rollover += self.interval
        if not self.utc:
            # If DST changes and midnight or weekly rollover, adjust for this.
            if self.when == "MIDNIGHT" or self.when.startswith("W"):
                dst = time.localtime(now)[-1]
                if dst != time.localtime(rollover)[-1]:
                    rollover += 3600 if dst else -3600
        self.rolloverAt = rollover

        if not self.delay:
            self.stream = self._open()

    # borrow *some* TimedRotatingFileHandler methods
    computeRollover = TimedRotatingFileHandler.computeRollover
    shouldRollover = TimedRotatingFileHandler.shouldRollover

在日志文件名中使用time.strftime()占位符,系统会自动替换为相应内容:

handler = TimedPatternFileHandler("logs/%H-%M.log", when="M", backupCount=2)

请注意,这将在您创建实例时清理旧文件。

2

我解决了问题,我的情况是日志文件的命名方式有问题。我尝试使用没有扩展名的"example"而不是使用"example.log"。

logHandler = handlers.TimedRotatingFileHandler('example', when='M', interval=1, backupCount=2)

在logging库的handlers.py文件中,特别是在getFilesToDelete()方法中,我注意到了一个小注释,它明确指出不应该做什么:
# See bpo-44753: Don't use the extension when computing the prefix.

尽管问题似乎已经解决,但这个答案可以帮助像我一样正在寻找解决方法的人。

-1

正如其他人已经指出的那样,backupCount 只有在您始终使用相同的文件名记录文件并定期进行轮换时才起作用。然后,您将拥有像 @Bsquare 指示的日志文件。 但是在我的情况下,我需要每天进行轮换,并且我的日志文件具有以下名称:2019-07-06.log2019-07-07.log2019-07-07.log,等等。 我发现使用当前实现的 TimedRotatingFileHandler 不可能做到这一点。

因此,我最终在 FileHandler 之上创建了适合我的需求的删除功能。 这是一个使用 FileHandler 的记录器类的简单示例,它将确保每次创建此类的实例时都会删除旧的日志文件:

import os
import datetime
import logging
import re
import pathlib


class Logger:

    # Maximum number of logs to store
    LOGS_COUNT = 3

    # Directory to log to
    LOGS_DIRECTORY = "logs"

    def __init__(self):
        # Make sure logs directory is created
        self.__create_directory(Logger.LOGS_DIRECTORY)
        # Clean old logs every time you create a logger
        self.__clean_old_logs()
        self.logger = logging.getLogger("Logger")
        # If condition will make sure logger handlers will be initialize only once when this object is created
        if not self.logger.handlers:
            self.logger.setLevel(logging.INFO)
            formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
            file_handler = logging.FileHandler("logs/{:%Y-%m-%d}.log".format(datetime.datetime.now()))
            file_handler.setFormatter(formatter)
            self.logger.addHandler(file_handler)

    def log_info(self, message):
        self.logger.info(message)

    def log_error(self, message):
        self.logger.error(message)

    def __clean_old_logs(self):
        for name in self.__get_old_logs():
            path = os.path.join(Logger.LOGS_DIRECTORY, name)
            self.__delete_file(path)

    def __get_old_logs(self):
        logs = [name for name in self.__get_file_names(Logger.LOGS_DIRECTORY)
            if re.match("([12]\d{3}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01]))\.log", name)]
        logs.sort(reverse=True)
        return logs[Logger.LOGS_COUNT:]

    def __get_file_names(self, path):
        return [item.name for item in pathlib.Path(path).glob("*") if item.is_file()]

    def __delete_file(self, path):
        os.remove(path)

    def __create_directory(self, directory):
        if not os.path.exists(directory):
            os.makedirs(directory)

然后你可以像这样使用它:

logger = Logger()
logger.log_info("This is a log message")

1
为什么要将逻辑放在logger中而不是处理程序的子类中?让logger负责意味着您无法将其与仅使用标准日志记录基础结构的任何库一起使用。 - Martijn Pieters
@MartijnPieters 这是我创建的一个简单代码,满足我的需求,不是通用解决方案。请随意改进它。 - Mykhailo Seniutovych

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接