1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355
| import os import sys import logging import threading from logging.handlers import BaseRotatingHandler from typing import Optional, Dict, Any, List, Union import allure from datetime import datetime, timedelta
try: PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) except Exception as e: PROJECT_ROOT = os.getcwd() print(f"自动计算项目根目录失败,使用当前工作目录: {PROJECT_ROOT},错误原因: {e}", file=sys.stderr)
ROOT_LOG_DIR = os.environ.get("LEOPARD_TEST_LOG_DIR", os.path.join(PROJECT_ROOT, "logs"))
LEVEL_DIR_MAP = { logging.DEBUG: "debug_log", logging.INFO: "info_log", logging.WARNING: "warning_log", logging.ERROR: "error_log", logging.CRITICAL: "critical_log" }
LEVEL_NAME_MAP = { logging.DEBUG: "debug", logging.INFO: "info", logging.WARNING: "warning", logging.ERROR: "error", logging.CRITICAL: "critical" }
logger_lock = threading.Lock()
def _ensure_level_dirs(): """确保所有日志级别文件夹存在""" try: if not os.path.exists(ROOT_LOG_DIR): os.makedirs(ROOT_LOG_DIR, exist_ok=True) for level_dir in LEVEL_DIR_MAP.values(): level_dir_path = os.path.join(ROOT_LOG_DIR, level_dir) if not os.path.exists(level_dir_path): os.makedirs(level_dir_path, exist_ok=True) except Exception as e: print(f"创建日志目录失败: {e}", file=sys.stderr)
class AllureLogHandler(logging.Handler): """同步日志文件到Allure报告(改为批量添加文件附件)""" def emit(self, record): """不再单个日志条目生成附件,改为在测试用例结束时批量添加日志文件""" pass
class LevelDirDailyFileHandler(BaseRotatingHandler): """ 按「级别文件夹+日期」存储日志 例如:logs/info_log/info_2026-02-27.log、logs/error_log/error_2026-02-27.log """
def __init__(self, target_level, encoding='utf-8', backupCount=7): self.target_level = target_level self.target_level_name = logging.getLevelName(target_level).lower() self.level_dir_name = LEVEL_DIR_MAP[target_level] self.level_dir_path = os.path.join(ROOT_LOG_DIR, self.level_dir_name)
self.backupCount = backupCount self.current_date = self._get_today() self.current_filename = os.path.join( self.level_dir_path, f"{self.target_level_name}_{self.current_date}.log" ) super().__init__(self.current_filename, 'a', encoding=encoding) self.setLevel(target_level)
def _get_today(self): """获取当天日期(YYYY-MM-DD)""" return datetime.now().strftime("%Y-%m-%d")
def shouldRollover(self, record): """仅跨天时切换文件""" today = self._get_today() if today != self.current_date: return True return False
def emit(self, record): """严格过滤:仅写入指定级别的日志""" if record.levelno != self.target_level: return super().emit(record)
def doRollover(self): """跨天切换文件,清理当前级别文件夹下的过期日志""" if self.stream: self.stream.close() self.stream = None
self.current_date = self._get_today() self.current_filename = os.path.join( self.level_dir_path, f"{self.target_level_name}_{self.current_date}.log" )
self._clean_old_logs()
self.stream = self._open()
def _clean_old_logs(self): """清理当前级别文件夹下的过期日志""" try: log_files = [ f for f in os.listdir(self.level_dir_path) if f.startswith(f"{self.target_level_name}_") and f.endswith('.log') ] if not log_files: return
def _parse_log_date(filename): """解析日志文件名中的日期""" date_str = filename.replace(f"{self.target_level_name}_", "").replace(".log", "") return datetime.strptime(date_str, "%Y-%m-%d")
log_files.sort(key=_parse_log_date)
if len(log_files) > self.backupCount: for old_file in log_files[:-self.backupCount]: old_file_path = os.path.join(self.level_dir_path, old_file) os.remove(old_file_path) logging.getLogger("LeopardTestLogger").info( f"清理过期日志文件:{old_file_path}" ) except Exception as e: logging.getLogger("LeopardTestLogger").error( f"清理{self.target_level_name}级别过期日志失败: {e}" )
class Logger: _instance: Optional['Logger'] = None _logger: Optional[logging.Logger] = None
def __new__(cls): with logger_lock: if cls._instance is None: cls._instance = super().__new__(cls) cls._instance._init_logger() return cls._instance
def _init_logger(self): if Logger._logger is not None: return
_ensure_level_dirs()
Logger._logger = logging.getLogger("LeopardTestLogger") Logger._logger.setLevel(logging.DEBUG) Logger._logger.propagate = False
formatter = logging.Formatter( '[%(asctime)s] [%(process)d-%(threadName)s] [%(filename)s:%(lineno)d] - %(levelname)s: %(message)s', datefmt='%Y-%m-%d %H:%M:%S' )
debug_handler = LevelDirDailyFileHandler(logging.DEBUG, backupCount=7) debug_handler.setFormatter(formatter)
info_handler = LevelDirDailyFileHandler(logging.INFO, backupCount=7) info_handler.setFormatter(formatter)
warning_handler = LevelDirDailyFileHandler(logging.WARNING, backupCount=7) warning_handler.setFormatter(formatter)
error_handler = LevelDirDailyFileHandler(logging.ERROR, backupCount=7) error_handler.setFormatter(formatter)
critical_handler = LevelDirDailyFileHandler(logging.CRITICAL, backupCount=7) critical_handler.setFormatter(formatter)
class ColoredStreamHandler(logging.StreamHandler): COLORS = { logging.DEBUG: '\033[0;36m', logging.INFO: '\033[0;32m', logging.WARNING: '\033[0;33m', logging.ERROR: '\033[0;31m', logging.CRITICAL: '\033[0;35m' } RESET = '\033[0m'
def format(self, record): msg = super().format(record) color = self.COLORS.get(record.levelno, self.RESET) return f"{color}{msg}{self.RESET}"
console_handler = ColoredStreamHandler(stream=sys.stdout) console_handler.setLevel(logging.DEBUG) console_handler.setFormatter(formatter)
allure_handler = AllureLogHandler() allure_handler.setFormatter(formatter)
if not Logger._logger.handlers: Logger._logger.addHandler(debug_handler) Logger._logger.addHandler(info_handler) Logger._logger.addHandler(warning_handler) Logger._logger.addHandler(error_handler) Logger._logger.addHandler(critical_handler) Logger._logger.addHandler(console_handler) Logger._logger.addHandler(allure_handler)
def debug(self, message: str) -> None: """DEBUG → logs/debug_log/debug_2026-02-27.log""" try: self._logger.debug(message.strip()) except Exception as e: print(f"[日志错误] 记录DEBUG日志失败: {str(e)}", file=sys.stderr)
def info(self, message: str) -> None: """INFO → logs/info_log/info_2026-02-27.log""" try: self._logger.info(message.strip()) except Exception as e: print(f"[日志错误] 记录INFO日志失败: {str(e)}", file=sys.stderr)
def warning(self, message: str) -> None: """WARNING → logs/warning_log/warning_2026-02-27.log""" try: self._logger.warning(message.strip()) except Exception as e: print(f"[日志错误] 记录WARNING日志失败: {str(e)}", file=sys.stderr)
def error(self, message: str, exc_info: bool = False) -> None: """ERROR → logs/error_log/error_2026-02-27.log""" try: self._logger.error(message.strip(), exc_info=exc_info) except Exception as e: print(f"[日志错误] 记录ERROR日志失败: {str(e)}", file=sys.stderr)
def critical(self, message: str, exc_info: bool = True) -> None: """CRITICAL → logs/critical_log/critical_2026-02-27.log""" try: self._logger.critical(message.strip(), exc_info=exc_info) except Exception as e: print(f"[日志错误] 记录CRITICAL日志失败: {str(e)}", file=sys.stderr)
def api_logs( self, url: str, data: Union[Dict[str, Any], str, None] = None, codes: Union[int, str, None] = None, responses: Union[Dict[str, Any], str, None] = None, data_name: Union[str, None] = None, data_list: Union[List[Any], None] = None ) -> None: """ 接口日志 → logs/info_log/info_2026-02-27.log 优化:参数支持空值,日志展示更友好 """ data = data or "无" codes = codes or "无" responses = responses or "无" data_name = data_name or "无" data_list = data_list or "无"
log_msg = ( "\n" + "=" * 80 + f"\n【请求URL】:{url}" f"\n【请求参数】:{data}" f"\n【返回状态码】:{codes}" f"\n【返回结果】:{responses}" f"\n【数据库验证字段】:{data_name}" f"\n【数据库查询数据】:{data_list}" + "\n" + "=" * 80 + "\n" ) self.info(log_msg)
def get_today_log_files(self) -> Dict[str, str]: """ 获取当天所有级别的日志文件路径 返回格式:{"info": "/logs/info_log/info_2026-03-01.log", ...} """ today = datetime.now().strftime("%Y-%m-%d") log_files = {} for level, level_name in LEVEL_NAME_MAP.items(): level_dir = LEVEL_DIR_MAP[level] log_file_path = os.path.join(ROOT_LOG_DIR, level_dir, f"{level_name}_{today}.log") log_files[level_name] = log_file_path return log_files
def add_log_files_to_allure(self): """ 将当天有内容的日志文件作为附件添加到Allure报告(对应当前测试用例) 仅添加文件大小>0的日志文件,避免空文件占用空间 """ try: today_log_files = self.get_today_log_files() for level_name, file_path in today_log_files.items(): if os.path.exists(file_path) and os.path.getsize(file_path) > 0: with open(file_path, 'r', encoding='utf-8') as f: log_content = f.read() allure.attach( body=log_content, name=f"{level_name}_log_{datetime.now().strftime('%Y-%m-%d')}.log", attachment_type=allure.attachment_type.TEXT, extension="log" ) self.info("已将当天有内容的日志文件添加到Allure报告") except Exception as e: self.error(f"添加日志文件到Allure报告失败: {e}", exc_info=True)
log = Logger()
|