我的项目的目标是实现一个 pypi 的包(当然,下面介绍的代码已经简化过,你可以直接在你的程序中使用),用户在使用的时候直接通过 pip 下载我的包后,通过暴露出来的对象,init 之后就可以实现一定功能。
其中包含日志收集,会将控制台的所有打印信息,不管是用户的还是包内的打印,都会在输出到终端的同时存储到日志文件中。我们实现了一个 Consoler 类,用来管理终端的一些信息。
你可以在 github 上看到包含这个功能的一个开源项目 SwanLab
这是一个类似 wandb 的,具有帮助你在 ai 训练中记录数据、本地可视化数据等等功能的 python 包
相关链接:
https://github.com/SwanHubX/SwanLab
https://pypi.org/search/?q=swanlab
一般用户在进行自己的程序的时候,都会有一些 print 输出,而 print 输出都是基于 python 中的标准输出流 sys.stdout
完成的。其主要过程是,print 会调用标准输出类上的 write 方法,而 write 方法默认会将 print 的处理结果输出到终端。
所以,对于我们的目标——将用户程序在终端的打印信息收集存储起来,我们就有一个方案了:重写 write 方法,在该方法中将获取到的打印信息存储到日志文件。
让我们先浏览一下实现代码,然后在后面的小节中描述细节。
你也先可以直接把下面代码复制一份,在使用的时候导入 Consoler 对象
import sys
import os
from datetime import datetime
class Consoler(sys.stdout.__class__):
def __init__(self):
super().__init__(sys.stdout.buffer)
self.original_stdout = sys.stdout # 保存原始的 sys.stdout
def init(self, path):
# path 是日志存储目录的目录路径,不是文件路径
# 通过当前日期生成日志文件名
self.now = datetime.now().strftime("%Y-%m-%d")
self.console_folder = path
# path 是否存在
if not os.path.exists(path):
os.makedirs(path)
# 日志文件路径
console_path = os.path.join(path, f"{self.now}.log")
# 日志文件,我建议是 utf-8 打开,不然可能会有乱码问题
self.console = open(console_path, "a", encoding="utf-8")
# 检查当前日期是否和控制台日志文件名一致
def __check_file_name(func):
"""装饰器,判断是否需要根据日期对控制台输出进行分片存储"""
def wrapper(self, *args, **kwargs):
now = datetime.now().strftime("%Y-%m-%d")
# 检测now是否和self.now一致
if now != self.now:
self.now = now
if hasattr(self, "console") and not self.console.closed:
self.console.close()
self.console = open(os.path.join(self.console_folder, self.now + ".log"), "a", encoding="utf-8")
return func(self, *args, **kwargs)
return wrapper
@__check_file_name
def write(self, message):
self.original_stdout.write(message) # 先写入原始 sys.stdout,即输出到控制台
self.original_stdout.flush()
self.console.write(message)
self.console.flush()
可以看到,在初始化的时候我们存储了一下原始的输出流,这是因为我们不能只将 write 方法重写成收集信息和写入文件,这样会阻断正常的终端输出,所以我们需要保存原始的输出流,在新的 write 方法中调用原来的 write 和 flush 方法。
原始 write
Write 方法会将传入的信息都加入缓冲区中(在类的实例化函数中,我们可以看到指定了缓冲区为标准输出流所有的缓冲区)
原始 flush
写入缓冲区后,什么时候输出?这里有两个规则:
还可以使用 super 上的 write 和 flush,但是因为后续可能用到和还原标准输出,所以我选择采用上述代码中的方式,将原始输出流保存一下,直接使用保存的对象。
日志按照日期进行分片存储,即同一天的日志存储在同一个日志文件中,并且以 yyyy-mm-dd.log 的格式进行命名,例如 2023-12-22.log。
这一原则的保证是通过装饰器 __check_file_name 完成的,在每次 write 之前都会检查当前日期和日志文件的对应关系,如果到了第二天,那么会自动创建新日志文件,并将存储定位到其中。
上面的 Consoler 类只是对终端打印进行了简单封装,如果想要在外部使用起来更方便和简单一些,建议可以二次封装一个类:
class CtrlConsoler:
def __init__(self):
self.consoler: Consoler = Consoler()
def init(self, path):
self.consoler.init(path)
sys.stdout = self.consoler
def reset(self):
"""重置输出为原本的样子"""
sys.stdout = self.consoler.original_stdout
这个时候,我们可以在 模块下的 init.py 中实例化 CtlConsoler,然后将实例暴露出去,在程序入口位置执行实例上的 init() 方法,这样别的地方导入的时候可以保证全局的终端控制系统是同一个,而不会重复记录。
如果你是在自己的程序内部需要完成一些控制台终端的信息打印和日志记录,我推荐使用 logging 模块,自己封装一个 MyLog 类就行。
这一部分的内容可以看我下一篇博客(没找到就是还没写,会尽快写的)。