Files
Medios-Macina/SYS/logger.py
T
2026-04-16 17:18:50 -07:00

323 lines
8.9 KiB
Python

"""Unified logging utility for automatic file and function name tracking."""
import sys
import inspect
import logging
import threading
from pathlib import Path
from typing import Any, Optional, Sequence
from SYS.rich_display import console_for
logger = logging.getLogger(__name__)
# Global DB logger set later to avoid circular imports
_DB_LOGGER = None
def set_db_logger(func):
global _DB_LOGGER
_DB_LOGGER = func
_DEBUG_ENABLED = False
_thread_local = threading.local()
def set_thread_stream(stream):
"""Set a custom output stream for the current thread."""
_thread_local.stream = stream
def get_thread_stream():
"""Get the custom output stream for the current thread, if any."""
return getattr(_thread_local, "stream", None)
def set_debug(enabled: bool) -> None:
"""Enable or disable debug logging."""
global _DEBUG_ENABLED
_DEBUG_ENABLED = enabled
def is_debug_enabled() -> bool:
"""Check if debug logging is enabled."""
return _DEBUG_ENABLED
def _debug_output_suppressed() -> bool:
try:
stderr_name = getattr(sys.stderr, "name", "")
return "nul" in str(stderr_name).lower() or "/dev/null" in str(stderr_name)
except Exception:
return False
def _debug_output_file(file=None):
stream = get_thread_stream()
if stream is not None:
return stream
if file is not None:
return file
return sys.stderr
def _is_rich_renderable(value: Any) -> bool:
if value is None:
return False
if isinstance(value, (str, bytes, bytearray)):
return False
return bool(
hasattr(value, "__rich_console__")
or hasattr(value, "__rich__")
or value.__class__.__module__.startswith("rich.")
)
def _caller_location(depth: int = 1) -> tuple[str, str]:
frame = inspect.currentframe()
current = frame
try:
for _ in range(max(0, int(depth))):
if current is None:
break
current = current.f_back
if current is None:
return "", ""
return Path(current.f_code.co_filename).stem, current.f_code.co_name
finally:
del frame
def _debug_db_log(*, caller_name: str, message: str, level: str = "DEBUG") -> None:
if not _DB_LOGGER:
return
try:
_DB_LOGGER(level, caller_name, message)
except Exception:
pass
def debug_panel(
title: str,
rows: Sequence[tuple[str, Any]],
*,
file=None,
border_style: str = "cyan",
) -> None:
"""Render a compact key/value debug panel when debug logging is enabled."""
if not _DEBUG_ENABLED or _debug_output_suppressed():
return
target_file = _debug_output_file(file)
try:
from rich.panel import Panel
from rich.table import Table as RichTable
grid = RichTable.grid(padding=(0, 1))
grid.add_column("Key", style="cyan", no_wrap=True)
grid.add_column("Value")
for key, val in rows:
try:
grid.add_row(str(key), str(val))
except Exception:
grid.add_row(str(key), "<unprintable>")
debug(
Panel(
grid,
title=str(title or "Debug"),
expand=False,
border_style=border_style,
),
file=target_file,
)
file_name, func_name = _caller_location(depth=2)
caller_name = f"{file_name}.{func_name}" if file_name and func_name else ""
_debug_db_log(
caller_name=caller_name,
message=f"[{title}] " + "; ".join(f"{k}={v}" for k, v in rows),
)
except Exception:
debug(title, list(rows), file=target_file)
def debug(*args, **kwargs) -> None:
"""Print debug message if debug logging is enabled.
Automatically prepends [filename.function_name] to all output.
"""
if not _DEBUG_ENABLED:
return
# Check if stderr has been redirected to /dev/null (quiet mode)
# If so, skip output to avoid queuing in background worker's capture
if _debug_output_suppressed():
return
target_file = _debug_output_file(kwargs.pop("file", None))
if len(args) == 1 and _is_rich_renderable(args[0]):
renderable = args[0]
console_for(target_file).print(renderable)
file_name, func_name = _caller_location(depth=1)
caller_name = f"{file_name}.{func_name}" if file_name and func_name else ""
_debug_db_log(caller_name=caller_name, message=f"<rich:{type(renderable).__name__}>")
return
# Prepend DEBUG label
args = ("DEBUG:", *args)
# Use the same logic as log()
log(*args, file=target_file, **kwargs)
def debug_inspect(
obj,
*,
title: str | None = None,
file=None,
methods: bool = False,
docs: bool = False,
private: bool = False,
dunder: bool = False,
sort: bool = True,
all: bool = False,
value: bool = True,
) -> None:
"""Rich-inspect an object when debug logging is enabled.
Uses the same stream / quiet-mode behavior as `debug()` and prepends a
`[file.function]` prefix when debug is enabled.
"""
if not _DEBUG_ENABLED:
return
# Mirror debug() quiet-mode guard.
if _debug_output_suppressed():
return
# Resolve destination stream.
file = _debug_output_file(file)
# Compute caller prefix (same as log()).
prefix = None
frame = inspect.currentframe()
if frame is not None and frame.f_back is not None:
caller_frame = frame.f_back
try:
file_name = Path(caller_frame.f_code.co_filename).stem
func_name = caller_frame.f_code.co_name
prefix = f"[{file_name}.{func_name}]"
finally:
del caller_frame
if frame is not None:
del frame
# Render.
from rich import inspect as rich_inspect
console = console_for(file)
# If the caller provides a title, treat it as authoritative.
# Only fall back to the automatic [file.func] prefix when no title is supplied.
effective_title = title
if not effective_title and prefix:
effective_title = prefix
# Show full identifiers (hashes/paths) without Rich shortening.
# Guard for older Rich versions which may not support max_* parameters.
try:
rich_inspect(
obj,
console=console,
title=effective_title,
methods=methods,
docs=docs,
private=private,
dunder=dunder,
sort=sort,
all=all,
value=value,
max_string=100_000,
max_length=100_000,
) # type: ignore[call-arg]
except TypeError:
rich_inspect(
obj,
console=console,
title=effective_title,
methods=methods,
docs=docs,
private=private,
dunder=dunder,
sort=sort,
all=all,
value=value,
)
def log(*args, **kwargs) -> None:
"""Print with automatic file.function prefix.
Automatically prepends [filename.function_name] to all output.
Defaults to stdout if not specified.
Example:
log("Upload started") # Output: [add_file.run] Upload started
"""
# When debug is disabled, suppress the automatic prefix for cleaner user-facing output.
add_prefix = _DEBUG_ENABLED
# Get the calling frame
frame = inspect.currentframe()
if frame is None:
file = kwargs.pop("file", sys.stdout)
sep = kwargs.pop("sep", " ")
end = kwargs.pop("end", "\n")
console_for(file).print(*args, sep=sep, end=end)
return
caller_frame = frame.f_back
if caller_frame is None:
file = kwargs.pop("file", sys.stdout)
sep = kwargs.pop("sep", " ")
end = kwargs.pop("end", "\n")
console_for(file).print(*args, sep=sep, end=end)
return
try:
# Get file name without extension
file_name = Path(caller_frame.f_code.co_filename).stem
# Get function name
func_name = caller_frame.f_code.co_name
# Check for thread-local stream first
stream = get_thread_stream()
if stream:
kwargs["file"] = stream
# Set default to stdout if not specified
elif "file" not in kwargs:
kwargs["file"] = sys.stdout
file = kwargs.pop("file", sys.stdout)
sep = kwargs.pop("sep", " ")
end = kwargs.pop("end", "\n")
if add_prefix:
prefix = f"[{file_name}.{func_name}]"
console_for(file).print(prefix, *args, sep=sep, end=end)
else:
console_for(file).print(*args, sep=sep, end=end)
# Log to database if available
if _DB_LOGGER:
try:
msg = sep.join(map(str, args))
level = "DEBUG" if add_prefix else "INFO"
_DB_LOGGER(level, f"{file_name}.{func_name}", msg)
except Exception:
pass
finally:
del frame
del caller_frame