ballistica/tools/bacommon/loggercontrol.py
2024-10-19 09:57:33 -07:00

119 lines
3.9 KiB
Python

# Released under the MIT License. See LICENSE for details.
#
"""System for managing loggers."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Annotated
from dataclasses import dataclass, field
from efro.dataclassio import ioprepped, IOAttrs
if TYPE_CHECKING:
from typing import Self
@ioprepped
@dataclass
class LoggerControlConfig:
"""A logging level configuration that applies to all loggers.
Any loggers not explicitly contained in the configuration will be
set to NOTSET.
"""
# Logger names mapped to log-level values (from system logging module).
levels: Annotated[dict[str, int], IOAttrs('l', store_default=False)] = (
field(default_factory=dict)
)
def apply(self) -> None:
"""Apply the config to all Python loggers."""
existinglognames = (
set(['root']) | logging.root.manager.loggerDict.keys()
)
# First, update levels for all existing loggers.
for logname in existinglognames:
logger = logging.getLogger(logname)
level = self.levels.get(logname)
if level is None:
level = logging.NOTSET
logger.setLevel(level)
# Next, assign levels to any loggers that don't exist.
for logname, level in self.levels.items():
if logname not in existinglognames:
logging.getLogger(logname).setLevel(level)
def would_make_changes(self) -> bool:
"""Return whether calling apply would change anything."""
existinglognames = (
set(['root']) | logging.root.manager.loggerDict.keys()
)
# Return True if we contain any nonexistent loggers. Even if
# we wouldn't change their level, the fact that we'd create
# them still counts as a difference.
if any(
logname not in existinglognames for logname in self.levels.keys()
):
return True
# Now go through all existing loggers and return True if we
# would change their level.
for logname in existinglognames:
logger = logging.getLogger(logname)
level = self.levels.get(logname)
if level is None:
level = logging.NOTSET
if logger.level != level:
return True
return False
def diff(self, baseconfig: LoggerControlConfig) -> LoggerControlConfig:
"""Return a config containing only changes compared to a base config.
Note that this omits all NOTSET values that resolve to NOTSET in
the base config.
This diffed config can later be used with apply_diff() against the
base config to recreate the state represented by self.
"""
cls = type(self)
config = cls()
for loggername, level in self.levels.items():
baselevel = baseconfig.levels.get(loggername, logging.NOTSET)
if level != baselevel:
config.levels[loggername] = level
return config
def apply_diff(
self, diffconfig: LoggerControlConfig
) -> LoggerControlConfig:
"""Apply a diff config to ourself."""
cls = type(self)
# Create a new config (with an indepenent levels dict copy).
config = cls(levels=dict(self.levels))
# Overlay the diff levels dict onto our new one.
config.levels.update(diffconfig.levels)
# Note: we do NOT prune NOTSET values here. This is so all
# loggers mentioned in the base config get created if we are
# applied, even if they are assigned a default level.
return config
@classmethod
def from_current_loggers(cls) -> Self:
"""Build a config from the current set of loggers."""
lognames = ['root'] + sorted(logging.root.manager.loggerDict)
config = cls()
for logname in lognames:
config.levels[logname] = logging.getLogger(logname).level
return config