"""Stdlib logging shim preserving legacy Loguru format and DSN behavior. Exports get_logger(name) returning a LoggerAdapter with optional `.bind(**kw)` for extra context. Console format mirrors the legacy `default_format`: "{YYYY-MM-DD at h:mm:ss TZ} | {LEVEL} | {message}". """ from __future__ import annotations import logging import os import sys from datetime import datetime from typing import Any try: # If owslogger is available, hook it; otherwise, skip quietly. from owslogger.logger import DSNHandler as _BaseDSNHandler # type: ignore except Exception: # pragma: no cover - optional _BaseDSNHandler = None # type: ignore # Environment-driven config (compatible names) LOGGER_NAME = os.environ.get("LOGGER_NAME", "sme-feed-file-exporter") CONSOLE_LOG_LEVEL = os.environ.get("CONSOLE_LOG_LEVEL", "INFO").upper() DEBUG_LOG = os.environ.get("DEBUG_LOG", "False").lower() in { "1", "true", "yes", } APP_VERSION = os.environ.get("APP_VERSION", "1.0") LOGGER_DSN = os.environ.get("LOGGER_DSN", "") LOGGER_LEVEL = os.environ.get("LOGGER_LEVEL", "INFO").upper() LOG_DIR = os.environ.get("LOG_DIR", "logs") MASTER_LOG = os.environ.get("MASTER_LOG", "") JSON_LOG = os.environ.get("JSON_LOG", "") CSV_LOG = os.environ.get("CSV_LOG", "") OTHER_LOG = os.environ.get("OTHER_LOG", "") ENVIRONMENT = os.environ.get("ENVIRONMENT", "").upper() # Format string mimicking Loguru default_format (minus color markup) DEFAULT_FORMAT = "{time} | {level} | {message}" class LegacyStyleFormatter(logging.Formatter): """Formatter that emits time/level/message in legacy order. Time format: YYYY-MM-DD at h:mm:ss TZ """ def format(self, record: logging.LogRecord) -> str: # noqa: D401 dt = datetime.fromtimestamp(record.created) # Note: tz omitted for simplicity; can add with time.localtime/strftime t = dt.strftime("%Y-%m-%d at %I:%M:%S %p") return f"{t} | {record.levelname} | {record.getMessage()}" class DsnHandler(logging.Handler): """Thin shim to wrap owslogger.DSNHandler when available.""" def __init__( self, dsn: str, environment: str, logger_name: str, version: str, ) -> None: super().__init__() self._impl = None if _BaseDSNHandler and dsn: self._impl = _BaseDSNHandler( dsn, environment, logger_name, version ) def emit(self, record: logging.LogRecord) -> None: # noqa: D401 if not self._impl: return # Inject correlation/session if present in extra corr = getattr(record, "correlation_id", None) sess = getattr(record, "session_id", None) # Mirror legacy behavior setattr(record, "correlation_id", corr) setattr(record, "session_id", sess) try: self._impl.emit(record) # type: ignore[attr-defined] except Exception: # DSN failures should not break application logging pass class BindAdapter(logging.LoggerAdapter): """Adapter supporting `.bind(**extras)` for compatibility.""" def bind(self, **extras: Any) -> "BindAdapter": merged = dict(self.extra) merged.update(extras) return BindAdapter(self.logger, merged) def _configure_once() -> None: root = logging.getLogger(LOGGER_NAME) if getattr(root, "_configured", False): # type: ignore[attr-defined] return root.setLevel( logging.DEBUG if DEBUG_LOG else getattr(logging, CONSOLE_LOG_LEVEL, logging.INFO) ) # Console handlers console_main = logging.StreamHandler(stream=sys.stderr) console_main.setLevel(getattr(logging, CONSOLE_LOG_LEVEL, logging.INFO)) console_main.setFormatter(LegacyStyleFormatter()) root.addHandler(console_main) console_err = logging.StreamHandler(stream=sys.stderr) console_err.setLevel(logging.ERROR) console_err.setFormatter(LegacyStyleFormatter()) root.addHandler(console_err) if DEBUG_LOG: console_dbg = logging.StreamHandler(stream=sys.stderr) console_dbg.setLevel(logging.DEBUG) console_dbg.setFormatter(LegacyStyleFormatter()) root.addHandler(console_dbg) # File handlers (MASTER_LOG only by default) from src.utils.paths import create_path_with_todays_date if any([MASTER_LOG, JSON_LOG, CSV_LOG, OTHER_LOG]): os.makedirs( create_path_with_todays_date( LOG_DIR, subdir=True, timestamp=True ), exist_ok=True, ) if MASTER_LOG: path = os.path.join(LOG_DIR, MASTER_LOG) fh = logging.handlers.RotatingFileHandler( path, maxBytes=5 * 1024 * 1024, backupCount=10 ) fh.setLevel(getattr(logging, CONSOLE_LOG_LEVEL, logging.INFO)) fh.setFormatter(logging.Formatter("{message}")) root.addHandler(fh) # DSN handler if LOGGER_DSN: dsn_h = DsnHandler(LOGGER_DSN, ENVIRONMENT, LOGGER_NAME, APP_VERSION) dsn_h.setLevel(getattr(logging, LOGGER_LEVEL, logging.INFO)) root.addHandler(dsn_h) # Bind correlation_id once adapter = BindAdapter( root, {"correlation_id": os.environ.get("CORRELATION_ID", "")} ) adapter.info(f"Correlation ID: {os.environ.get('CORRELATION_ID', '')}") setattr(root, "_configured", True) # type: ignore[attr-defined] def get_logger(name: str = LOGGER_NAME) -> BindAdapter: """Return a preconfigured logger adapter matching legacy usage.""" _configure_once() base = logging.getLogger(name) return BindAdapter(base, {})