import logging import re import contextvars import os import sys import time from contextlib import contextmanager from logging.handlers import TimedRotatingFileHandler from pathlib import Path LOG_DIR = Path(__file__).parent / "logs" LOG_FILE = LOG_DIR / "sunat.log" _LOG_CONFIGURED = False _ANSI_RE = re.compile(r"\033\[[0-9;]*m") _stack: contextvars.ContextVar[list] = contextvars.ContextVar("log_stack", default=[]) # ============================================================================ class TreeFormatter(logging.Formatter): def format(self, record): return f"{self.formatTime(record)} ─── {record.getMessage()}" def formatTime(self, record, datefmt=None): import datetime return datetime.datetime.fromtimestamp(record.created).strftime("%Y-%m-%d %H:%M:%S") # ============================================================================ class PlainFileFormatter(TreeFormatter): """Igual que TreeFormatter pero sin códigos ANSI de color, para el archivo.""" def format(self, record): return _ANSI_RE.sub("", super().format(record)) # ============================================================================ def _build_prefix(stack, is_last): if not stack: return "" parts = [] for sib_is_last in stack[:-1]: parts.append(" " if sib_is_last else "│ ") parts.append("└── " if is_last else "├── ") return "".join(parts) # ============================================================================ class _TaskContext: def __init__(self, logger, msg, emoji, is_last): self._logger = logger self._msg = msg self._emoji = emoji self._is_last = is_last def __enter__(self): stack = _stack.get() prefix = _build_prefix(stack, self._is_last) emoji_str = f"{self._emoji} " if self._emoji else "" self._logger._logger.info(f"{prefix}{emoji_str}{self._msg}") _stack.set(stack + [self._is_last]) return TaskLogger(self._logger._logger) def __exit__(self, exc_type, exc_val, exc_tb): _stack.set(_stack.get()[:-1]) # ============================================================================ class TaskLogger: def __init__(self, logger): self._logger = logger @contextmanager def task(self, msg, emoji=""): with _TaskContext(self, msg, emoji, is_last=False) as sub_logger: yield sub_logger @contextmanager def last_task(self, msg, emoji=""): with _TaskContext(self, msg, emoji, is_last=True) as sub_logger: yield sub_logger def info(self, msg, *args, _last=False, **kwargs): self._log(logging.INFO, msg, args, kwargs, _last) def warning(self, msg, *args, _last=False, **kwargs): self._log(logging.WARNING, msg, args, kwargs, _last) def error(self, msg, *args, _last=False, **kwargs): self._log(logging.ERROR, msg, args, kwargs, _last) def _log(self, level, msg, args, kwargs, is_last): stack = _stack.get() prefix = _build_prefix(stack, is_last) self._logger.log(level, f"{prefix}{msg}", *args, **kwargs) def _rotated_name(default_name): """TimedRotatingFileHandler entrega 'sunat.log.2026-08-30'; lo convertimos a 'sunat_2026-08-30.log' para que sea más legible como nombre de archivo.""" date_part = default_name.rsplit(".", 1)[-1] return str(LOG_FILE.with_name(f"{LOG_FILE.stem}_{date_part}{LOG_FILE.suffix}")) # ============================================================================ def limpiar_logs_antiguos(dias=7): """Borra los logs rotados (sunat_YYYY-MM-DD.log) con más de 'dias' de antigüedad.""" limite = time.time() - dias * 86400 count = 0 for p in LOG_DIR.glob(f"{LOG_FILE.stem}_*{LOG_FILE.suffix}"): try: if p.stat().st_mtime < limite: p.unlink() count += 1 except OSError: pass return count # ============================================================================ def setup_logging(level=None, console=True): global _LOG_CONFIGURED if _LOG_CONFIGURED: return if level is None: level_name = os.getenv("LOG_LEVEL", "INFO").upper() level = getattr(logging, level_name, logging.INFO) LOG_DIR.mkdir(exist_ok=True) file_handler = TimedRotatingFileHandler( LOG_FILE, when="midnight", interval=1, backupCount=0, # la retención la maneja limpiar_logs_antiguos() con el namer de abajo encoding="utf-8", ) file_handler.namer = _rotated_name file_handler.setFormatter(PlainFileFormatter()) handlers = [file_handler] if console: console_handler = logging.StreamHandler(sys.stdout) console_handler.setFormatter(TreeFormatter()) handlers.append(console_handler) logging.basicConfig(level=level, handlers=handlers, force=True) _LOG_CONFIGURED = True # ============================================================================ def get_logger(name="sunat"): setup_logging() return TaskLogger(logging.getLogger(name))