417 lines
15 KiB
Python
417 lines
15 KiB
Python
"""
|
|
개선된 Logger 모듈 - GUI와 파일 로그 분리, 자동 정리 기능
|
|
|
|
GUI 로그 레벨 권장 설정:
|
|
- 일반 사용자: logging.INFO (정보성 메시지만)
|
|
- 개발자: logging.DEBUG (모든 메시지)
|
|
- 운영 환경: logging.WARNING (경고 이상만)
|
|
|
|
사용 예시:
|
|
```python
|
|
# 기본 사용
|
|
logger = Logger(
|
|
gui_logger=your_gui_function,
|
|
log_file="logs/app.log",
|
|
gui_log_level=logging.INFO # GUI에는 INFO 이상만
|
|
)
|
|
|
|
# 동적 레벨 변경
|
|
logger.set_gui_log_level(logging.WARNING) # GUI에는 경고만
|
|
logger.set_file_log_level(logging.DEBUG) # 파일에는 모든 로그
|
|
```
|
|
"""
|
|
import re
|
|
import sys
|
|
import logging
|
|
from logging.handlers import TimedRotatingFileHandler
|
|
import os
|
|
import glob
|
|
import time
|
|
import threading
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
from PySide6.QtCore import QObject, Signal
|
|
import traceback
|
|
|
|
# 로그 레벨 이름 매핑 (안전하고 호환성 좋은 방법)
|
|
def get_level_name(level):
|
|
"""로그 레벨을 이름으로 변환 (호환성 보장)"""
|
|
level_names = {
|
|
logging.NOTSET: "NOTSET",
|
|
logging.DEBUG: "DEBUG",
|
|
logging.INFO: "INFO",
|
|
logging.WARNING: "WARNING",
|
|
logging.ERROR: "ERROR",
|
|
logging.CRITICAL: "CRITICAL"
|
|
}
|
|
return level_names.get(level, f"Level {level}")
|
|
|
|
class Logger(QObject):
|
|
log_signal = Signal(str) # GUI로 로그 메시지를 전달할 시그널
|
|
|
|
def __init__(self, gui_logger=None, log_file="Edit_PartTimer_log.log", logger_name="Edit_PartTimer_log",
|
|
file_log_level=logging.DEBUG, gui_log_level=logging.INFO,
|
|
max_days=2, cleanup_interval=3600):
|
|
"""
|
|
개선된 Logger 초기화
|
|
:param gui_logger: GUI에 로그를 출력할 콜백 함수
|
|
:param log_file: 로그 파일 이름
|
|
:param logger_name: 로거 이름
|
|
:param file_log_level: 파일 로거의 로그 레벨
|
|
:param gui_log_level: GUI 로거의 로그 레벨
|
|
:param max_days: 로그 파일 보관 일수 (기본 3일)
|
|
:param cleanup_interval: 정리 작업 간격(초, 기본 1시간)
|
|
"""
|
|
super().__init__()
|
|
self.gui_logger = gui_logger
|
|
self.file_log_level = file_log_level
|
|
self.gui_log_level = gui_log_level
|
|
self.max_days = max_days
|
|
self.cleanup_interval = cleanup_interval
|
|
self.log_dir = Path(log_file).parent
|
|
self.log_base_name = Path(log_file).stem
|
|
|
|
# 로그 디렉토리 생성
|
|
self.log_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# 로그 설정
|
|
self.logger = logging.getLogger(logger_name)
|
|
self.logger.setLevel(file_log_level)
|
|
|
|
# 기존 핸들러 제거 (중복 방지)
|
|
for handler in self.logger.handlers[:]:
|
|
self.logger.removeHandler(handler)
|
|
|
|
# 포맷 설정
|
|
self.simple_format = "[%(asctime)s] [%(levelname)s] %(message)s"
|
|
self.detailed_format = (
|
|
"[%(asctime)s] [%(threadName)s] [%(levelname)s] "
|
|
"[%(filename)s:%(funcName)s:%(lineno)d] %(message)s"
|
|
)
|
|
# GUI 전용 간결한 포맷 (시간:분:초 + 메시지만)
|
|
self.gui_format = "[%(asctime)s] %(message)s"
|
|
|
|
# 핸들러 추가
|
|
self._add_console_handler(file_log_level)
|
|
self._add_file_handler(log_file, file_log_level)
|
|
|
|
# GUI Logger 연결
|
|
if self.gui_logger:
|
|
self.log_signal.connect(self.gui_logger)
|
|
|
|
# 자동 정리 스레드 시작
|
|
self._start_cleanup_thread()
|
|
|
|
def _add_console_handler(self, level):
|
|
"""콘솔 핸들러 추가"""
|
|
console_handler = logging.StreamHandler()
|
|
console_handler.setLevel(level)
|
|
formatter = logging.Formatter(
|
|
self.detailed_format if level <= logging.DEBUG else self.simple_format
|
|
)
|
|
console_handler.setFormatter(formatter)
|
|
self.logger.addHandler(console_handler)
|
|
|
|
def _add_file_handler(self, log_file, level):
|
|
"""개선된 시간 기반 파일 핸들러 추가"""
|
|
# 확장자가 .log가 아니면 .log로 변경
|
|
if not log_file.endswith('.log'):
|
|
base_name, _ = os.path.splitext(log_file)
|
|
log_file = base_name + '.log'
|
|
|
|
# 시간 기반 로테이팅 핸들러 사용 (매일 자정에 로테이션)
|
|
file_handler = TimedRotatingFileHandler(
|
|
log_file,
|
|
when='midnight', # 매일 자정에 로테이션
|
|
interval=1, # 1일 간격
|
|
backupCount=self.max_days, # 보관할 파일 수
|
|
encoding="utf-8",
|
|
utc=False
|
|
)
|
|
|
|
# 로테이션된 파일명 형식 설정 (YYYY-MM-DD)
|
|
file_handler.suffix = "%Y-%m-%d.log"
|
|
# file_handler.extMatch = r"^\d{4}-\d{2}-\d{2}\.log$"
|
|
|
|
# Python 3.11 호환성을 위해 정규식 패턴을 re.Pattern 객체로 변환
|
|
file_handler.extMatch = re.compile(r"^\d{4}-\d{2}-\d{2}\.log$")
|
|
|
|
file_handler.setLevel(level)
|
|
formatter = logging.Formatter(
|
|
self.detailed_format if level <= logging.DEBUG else self.simple_format
|
|
)
|
|
file_handler.setFormatter(formatter)
|
|
self.logger.addHandler(file_handler)
|
|
|
|
# 핸들러 참조 저장 (정리 작업용)
|
|
self.file_handler = file_handler
|
|
|
|
def _start_cleanup_thread(self):
|
|
"""자동 정리 스레드 시작"""
|
|
def cleanup_worker():
|
|
while True:
|
|
try:
|
|
self._cleanup_old_logs()
|
|
time.sleep(self.cleanup_interval)
|
|
except Exception as e:
|
|
# 정리 작업 실패 시에도 계속 동작
|
|
pass
|
|
|
|
cleanup_thread = threading.Thread(target=cleanup_worker, daemon=True)
|
|
cleanup_thread.start()
|
|
|
|
def _cleanup_old_logs(self):
|
|
"""오래된 로그 파일 정리"""
|
|
try:
|
|
cutoff_date = datetime.now() - timedelta(days=self.max_days)
|
|
log_pattern = f"{self.log_base_name}*.log"
|
|
|
|
for log_file in self.log_dir.glob(log_pattern):
|
|
try:
|
|
# 파일 수정 시간 확인
|
|
file_mtime = datetime.fromtimestamp(log_file.stat().st_mtime)
|
|
|
|
# 현재 활성 로그 파일은 삭제하지 않음
|
|
if log_file.name == f"{self.log_base_name}.log":
|
|
continue
|
|
|
|
# 오래된 파일 삭제
|
|
if file_mtime < cutoff_date:
|
|
log_file.unlink()
|
|
self.logger.info(f"오래된 로그 파일 삭제: {log_file.name}")
|
|
|
|
except Exception as e:
|
|
self.logger.warning(f"로그 파일 정리 중 오류: {log_file.name} - {e}")
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"로그 정리 작업 실패: {e}")
|
|
|
|
def log(self, message, level=logging.INFO, exc_info=False):
|
|
"""로그 메시지 기록"""
|
|
if exc_info:
|
|
message = f"{message}\n{traceback.format_exc()}"
|
|
|
|
# 호출 위치 정보를 동적으로 추출 (sys._getframe 사용)
|
|
try:
|
|
# sys._getframe(1)은 이 함수를 호출한 프레임을 반환
|
|
caller_frame = sys._getframe(1)
|
|
filename = caller_frame.f_code.co_filename
|
|
lineno = caller_frame.f_lineno
|
|
func_name = caller_frame.f_code.co_name
|
|
except (AttributeError, ValueError) as e:
|
|
# _getframe이 지원되지 않거나 깊이가 잘못된 경우
|
|
filename = "(unknown file)"
|
|
lineno = 0
|
|
func_name = "(unknown function)"
|
|
except Exception as e:
|
|
# 기타 예외 발생 시 fallback
|
|
filename = "(unknown file)"
|
|
lineno = 0
|
|
func_name = "(unknown function)"
|
|
|
|
record = self.logger.makeRecord(
|
|
self.logger.name, level, filename,
|
|
lineno, message, None, None, func_name
|
|
)
|
|
|
|
# 파일 로거에 메시지 전달
|
|
if level >= self.file_log_level:
|
|
self.logger.handle(record)
|
|
|
|
# GUI 로그로 전달 (포맷 적용)
|
|
if self.gui_logger and level >= self.gui_log_level:
|
|
# GUI용 간결한 포맷터 (시간:분:초만 표시)
|
|
gui_formatter = logging.Formatter(
|
|
fmt="[%(asctime)s] %(message)s",
|
|
datefmt="%H:%M:%S" # 시:분:초만 표시
|
|
)
|
|
formatted_message = gui_formatter.format(record)
|
|
colored_message = self.format_gui_message(formatted_message, level)
|
|
self.log_signal.emit(colored_message)
|
|
|
|
def format_gui_message(self, message, level):
|
|
"""GUI 로그 메시지의 HTML 색상 지정"""
|
|
color_map = {
|
|
logging.DEBUG: "#808080", # 회색
|
|
logging.INFO: "#000000", # 검정
|
|
logging.WARNING: "#FF8C00", # 주황
|
|
logging.ERROR: "#DC143C", # 빨강
|
|
logging.CRITICAL: "#8B008B", # 보라
|
|
}
|
|
color = color_map.get(level, "#000000")
|
|
return f'<span style="color:{color};">{message}</span>'
|
|
|
|
def set_gui_logger(self, gui_logger, gui_log_level=None):
|
|
"""
|
|
GUI 로그 콜백 함수를 설정하고, log_signal에 연결합니다.
|
|
선택적으로 GUI 로그 레벨도 변경할 수 있습니다.
|
|
"""
|
|
self.gui_logger = gui_logger
|
|
self.log_signal.connect(gui_logger)
|
|
|
|
if gui_log_level is not None:
|
|
self.gui_log_level = gui_log_level
|
|
|
|
def set_gui_log_level(self, level):
|
|
"""GUI 로그 레벨을 동적으로 변경합니다"""
|
|
self.gui_log_level = level
|
|
level_name = get_level_name(level)
|
|
self.logger.info(f"GUI 로그 레벨이 {level_name}로 변경되었습니다")
|
|
|
|
def set_file_log_level(self, level):
|
|
"""파일 로그 레벨을 동적으로 변경합니다"""
|
|
self.file_log_level = level
|
|
self.logger.setLevel(level)
|
|
for handler in self.logger.handlers:
|
|
if isinstance(handler, (logging.FileHandler, TimedRotatingFileHandler)):
|
|
handler.setLevel(level)
|
|
level_name = get_level_name(level)
|
|
self.logger.info(f"파일 로그 레벨이 {level_name}로 변경되었습니다")
|
|
|
|
def get_log_levels(self):
|
|
"""현재 로그 레벨 정보 반환"""
|
|
return {
|
|
"file_level": get_level_name(self.file_log_level),
|
|
"gui_level": get_level_name(self.gui_log_level),
|
|
"logger_level": get_level_name(self.logger.level)
|
|
}
|
|
|
|
def get_log_info(self):
|
|
"""로그 파일 정보 반환"""
|
|
try:
|
|
log_files = list(self.log_dir.glob(f"{self.log_base_name}*.log"))
|
|
total_size = sum(f.stat().st_size for f in log_files)
|
|
|
|
return {
|
|
"log_dir": str(self.log_dir),
|
|
"total_files": len(log_files),
|
|
"total_size_mb": total_size / (1024 * 1024),
|
|
"max_days": self.max_days,
|
|
"files": [f.name for f in log_files]
|
|
}
|
|
except Exception:
|
|
return {"error": "로그 정보 조회 실패"}
|
|
|
|
def force_cleanup(self):
|
|
"""수동 정리 실행"""
|
|
self._cleanup_old_logs()
|
|
|
|
# 파이썬 표준 로깅 인터페이스 지원
|
|
def debug(self, message, *args, **kwargs):
|
|
"""DEBUG 레벨 로그"""
|
|
if args:
|
|
message = message % args
|
|
self.log(message, level=logging.DEBUG, exc_info=kwargs.get('exc_info', False))
|
|
|
|
def info(self, message, *args, **kwargs):
|
|
"""INFO 레벨 로그"""
|
|
if args:
|
|
message = message % args
|
|
self.log(message, level=logging.INFO, exc_info=kwargs.get('exc_info', False))
|
|
|
|
def warning(self, message, *args, **kwargs):
|
|
"""WARNING 레벨 로그"""
|
|
if args:
|
|
message = message % args
|
|
self.log(message, level=logging.WARNING, exc_info=kwargs.get('exc_info', False))
|
|
|
|
def error(self, message, *args, **kwargs):
|
|
"""ERROR 레벨 로그"""
|
|
if args:
|
|
message = message % args
|
|
self.log(message, level=logging.ERROR, exc_info=kwargs.get('exc_info', False))
|
|
|
|
def critical(self, message, *args, **kwargs):
|
|
"""CRITICAL 레벨 로그"""
|
|
if args:
|
|
message = message % args
|
|
self.log(message, level=logging.CRITICAL, exc_info=kwargs.get('exc_info', False))
|
|
|
|
def exception(self, message, *args, **kwargs):
|
|
"""ERROR 레벨로 예외 정보와 함께 로그"""
|
|
if args:
|
|
message = message % args
|
|
self.log(message, level=logging.ERROR, exc_info=True)
|
|
|
|
# 전역 기본 로거 인스턴스
|
|
_default_logger = None
|
|
|
|
def get_default_logger():
|
|
"""기본 로거 인스턴스 반환"""
|
|
global _default_logger
|
|
if _default_logger is None:
|
|
_default_logger = Logger(log_file="logs/default.log")
|
|
return _default_logger
|
|
|
|
def set_default_logger(logger):
|
|
"""기본 로거 설정"""
|
|
global _default_logger
|
|
_default_logger = logger
|
|
|
|
# 편의 함수들 - 실제 구현
|
|
def debug(msg, *args, **kwargs):
|
|
"""편의 함수 - DEBUG 레벨 로그"""
|
|
get_default_logger().debug(msg, *args, **kwargs)
|
|
|
|
def info(msg, *args, **kwargs):
|
|
"""편의 함수 - INFO 레벨 로그"""
|
|
get_default_logger().info(msg, *args, **kwargs)
|
|
|
|
def warning(msg, *args, **kwargs):
|
|
"""편의 함수 - WARNING 레벨 로그"""
|
|
get_default_logger().warning(msg, *args, **kwargs)
|
|
|
|
def error(msg, *args, **kwargs):
|
|
"""편의 함수 - ERROR 레벨 로그"""
|
|
get_default_logger().error(msg, *args, **kwargs)
|
|
|
|
def critical(msg, *args, **kwargs):
|
|
"""편의 함수 - CRITICAL 레벨 로그"""
|
|
get_default_logger().critical(msg, *args, **kwargs)
|
|
|
|
def exception(msg, *args, **kwargs):
|
|
"""편의 함수 - 예외 정보와 함께 로그"""
|
|
get_default_logger().exception(msg, *args, **kwargs)
|
|
|
|
|
|
# 구조화된 로깅을 위한 추가 클래스
|
|
class StructuredLogger(Logger):
|
|
"""JSON 형태의 구조화된 로깅을 지원하는 로거"""
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
|
|
def log_structured(self, event, level=logging.INFO, **context):
|
|
"""구조화된 로그 기록"""
|
|
import json
|
|
|
|
log_data = {
|
|
"timestamp": datetime.now().isoformat(),
|
|
"event": event,
|
|
"level": get_level_name(level),
|
|
**context
|
|
}
|
|
|
|
message = json.dumps(log_data, ensure_ascii=False, separators=(',', ':'))
|
|
self.log(message, level)
|
|
|
|
def log_performance(self, operation, duration, **context):
|
|
"""성능 로그 기록"""
|
|
self.log_structured(
|
|
"performance",
|
|
level=logging.INFO,
|
|
operation=operation,
|
|
duration_ms=round(duration * 1000, 2),
|
|
**context
|
|
)
|
|
|
|
def log_error_with_context(self, error, **context):
|
|
"""에러와 컨텍스트를 함께 로그"""
|
|
self.log_structured(
|
|
"error",
|
|
level=logging.ERROR,
|
|
error_type=type(error).__name__,
|
|
error_message=str(error),
|
|
**context
|
|
)
|