# Released under the MIT License. See LICENSE for details.#"""System for managing loggers."""from__future__importannotationsimportloggingfromtypingimportTYPE_CHECKING,Annotatedfromdataclassesimportdataclass,fieldfromefro.dataclassioimportioprepped,IOAttrsifTYPE_CHECKING:fromtypingimportSelf,Sequence
[docs]@ioprepped@dataclassclassLoggerControlConfig:"""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))
[docs]defapply(self,*,warn_unexpected_loggers:bool=False,warn_missing_loggers:bool=False,ignore_log_prefixes:list[str]|None=None,)->None:"""Apply the config to all Python loggers. If 'warn_unexpected_loggers' is True, warnings will be issues for any loggers not explicitly covered by the config. This is useful to help ensure controls for all possible loggers are present in a UI/etc. If 'warn_missing_loggers' is True, warnings will be issued for any loggers present in the config that are not found at apply time. This can be useful for pruning settings for no longer used loggers. Warnings for any log names beginning with any strings in 'ignore_log_prefixes' will be suppressed. This can allow ignoring loggers associated with submodules for a given package and instead presenting only a top level logger (or none at all). """ifignore_log_prefixesisNone:ignore_log_prefixes=[]existinglognames=(set(['root'])|logging.root.manager.loggerDict.keys())# First issue any warnings they want.ifwarn_unexpected_loggers:forlognameinsorted(existinglognames):iflognamenotinself.levelsandnotany(logname.startswith(pre)forpreinignore_log_prefixes):logging.warning('Found a logger not covered by LoggerControlConfig:'" '%s'.",logname,)ifwarn_missing_loggers:forlognameinsorted(self.levels.keys()):iflognamenotinexistinglognamesandnotany(logname.startswith(pre)forpreinignore_log_prefixes):logging.warning('Logger covered by LoggerControlConfig does not exist:'' %s.',logname,)# First, update levels for all existing loggers.forlognameinexistinglognames:logger=logging.getLogger(logname)level=self.levels.get(logname)iflevelisNone:level=logging.NOTSETlogger.setLevel(level)# Next, assign levels to any loggers that don't exist.forlogname,levelinself.levels.items():iflognamenotinexistinglognames:logging.getLogger(logname).setLevel(level)
[docs]defsanity_check_effective_levels(self)->None:"""Checks existing loggers to make sure they line up with us. This can be called periodically to ensure that a control-config is properly driving log levels and that nothing else is changing them behind our back. """existinglognames=(set(['root'])|logging.root.manager.loggerDict.keys())forlognameinexistinglognames:logger=logging.getLogger(logname)iflogger.getEffectiveLevel()!=self.get_effective_level(logname):logging.error('loggercontrol effective-level sanity check failed;'' expected logger %s to have effective level %s'' but it has %s.',logname,logging.getLevelName(self.get_effective_level(logname)),logging.getLevelName(logger.getEffectiveLevel()),)
[docs]defget_effective_level(self,logname:str)->int:"""Given a log name, predict its level if this config is applied."""splits=logname.split('.')splen=len(splits)foriinrange(splen):subname='.'.join(splits[:splen-i])thisval=self.levels.get(subname)ifthisvalisnotNoneandthisval!=logging.NOTSET:returnthisval# Haven't found anything; just return root value.thisval=self.levels.get('root')return(logging.DEBUGifthisvalisNoneelselogging.DEBUGifthisval==logging.NOTSETelsethisval)
[docs]defwould_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.ifany(lognamenotinexistinglognamesforlognameinself.levels.keys()):returnTrue# Now go through all existing loggers and return True if we# would change their level.forlognameinexistinglognames:logger=logging.getLogger(logname)level=self.levels.get(logname)iflevelisNone:level=logging.NOTSETiflogger.level!=level:returnTruereturnFalse
[docs]defdiff(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()forloggername,levelinself.levels.items():baselevel=baseconfig.levels.get(loggername,logging.NOTSET)iflevel!=baselevel:config.levels[loggername]=levelreturnconfig
[docs]defapply_diff(self,diffconfig:LoggerControlConfig)->LoggerControlConfig:"""Apply a diff config to ourself. Note that values that resolve to NOTSET are left intact in the output config. This is so all loggers expected by either the base or diff config to exist can be created if desired/etc. """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.returnconfig
[docs]@classmethoddeffrom_current_loggers(cls)->Self:"""Build a config from the current set of loggers."""lognames=['root']+sorted(logging.root.manager.loggerDict)config=cls()forlognameinlognames:config.levels[logname]=logging.getLogger(logname).levelreturnconfig