bacommon.loggercontrol
System for managing loggers.
1# Released under the MIT License. See LICENSE for details. 2# 3"""System for managing loggers.""" 4 5from __future__ import annotations 6 7import logging 8from typing import TYPE_CHECKING, Annotated 9from dataclasses import dataclass, field 10 11from efro.dataclassio import ioprepped, IOAttrs 12 13if TYPE_CHECKING: 14 from typing import Self, Sequence 15 16 17@ioprepped 18@dataclass 19class LoggerControlConfig: 20 """A logging level configuration that applies to all loggers. 21 22 Any loggers not explicitly contained in the configuration will be 23 set to NOTSET. 24 """ 25 26 # Logger names mapped to log-level values (from system logging 27 # module). 28 levels: Annotated[dict[str, int], IOAttrs('l', store_default=False)] = ( 29 field(default_factory=dict) 30 ) 31 32 def apply( 33 self, 34 *, 35 warn_unexpected_loggers: bool = False, 36 warn_missing_loggers: bool = False, 37 ignore_log_prefixes: list[str] | None = None, 38 ) -> None: 39 """Apply the config to all Python loggers. 40 41 If 'warn_unexpected_loggers' is True, warnings will be issues for 42 any loggers not explicitly covered by the config. This is useful 43 to help ensure controls for all possible loggers are present in 44 a UI/etc. 45 46 If 'warn_missing_loggers' is True, warnings will be issued for 47 any loggers present in the config that are not found at apply time. 48 This can be useful for pruning settings for no longer used loggers. 49 50 Warnings for any log names beginning with any strings in 51 'ignore_log_prefixes' will be suppressed. This can allow 52 ignoring loggers associated with submodules for a given package 53 and instead presenting only a top level logger (or none at all). 54 """ 55 if ignore_log_prefixes is None: 56 ignore_log_prefixes = [] 57 58 existinglognames = ( 59 set(['root']) | logging.root.manager.loggerDict.keys() 60 ) 61 62 # First issue any warnings they want. 63 if warn_unexpected_loggers: 64 for logname in sorted(existinglognames): 65 if logname not in self.levels and not any( 66 logname.startswith(pre) for pre in ignore_log_prefixes 67 ): 68 logging.warning( 69 'Found a logger not covered by LoggerControlConfig:' 70 " '%s'.", 71 logname, 72 ) 73 if warn_missing_loggers: 74 for logname in sorted(self.levels.keys()): 75 if logname not in existinglognames and not any( 76 logname.startswith(pre) for pre in ignore_log_prefixes 77 ): 78 logging.warning( 79 'Logger covered by LoggerControlConfig does not exist:' 80 ' %s.', 81 logname, 82 ) 83 84 # First, update levels for all existing loggers. 85 for logname in existinglognames: 86 logger = logging.getLogger(logname) 87 level = self.levels.get(logname) 88 if level is None: 89 level = logging.NOTSET 90 logger.setLevel(level) 91 92 # Next, assign levels to any loggers that don't exist. 93 for logname, level in self.levels.items(): 94 if logname not in existinglognames: 95 logging.getLogger(logname).setLevel(level) 96 97 def sanity_check_effective_levels(self) -> None: 98 """Checks existing loggers to make sure they line up with us. 99 100 This can be called periodically to ensure that a control-config 101 is properly driving log levels and that nothing else is changing 102 them behind our back. 103 """ 104 105 existinglognames = ( 106 set(['root']) | logging.root.manager.loggerDict.keys() 107 ) 108 for logname in existinglognames: 109 logger = logging.getLogger(logname) 110 if logger.getEffectiveLevel() != self.get_effective_level(logname): 111 logging.error( 112 'loggercontrol effective-level sanity check failed;' 113 ' expected logger %s to have effective level %s' 114 ' but it has %s.', 115 logname, 116 logging.getLevelName(self.get_effective_level(logname)), 117 logging.getLevelName(logger.getEffectiveLevel()), 118 ) 119 120 def get_effective_level(self, logname: str) -> int: 121 """Given a log name, predict its level if this config is applied.""" 122 splits = logname.split('.') 123 124 splen = len(splits) 125 for i in range(splen): 126 subname = '.'.join(splits[: splen - i]) 127 thisval = self.levels.get(subname) 128 if thisval is not None and thisval != logging.NOTSET: 129 return thisval 130 131 # Haven't found anything; just return root value. 132 thisval = self.levels.get('root') 133 return ( 134 logging.DEBUG 135 if thisval is None 136 else logging.DEBUG if thisval == logging.NOTSET else thisval 137 ) 138 139 def would_make_changes(self) -> bool: 140 """Return whether calling apply would change anything.""" 141 142 existinglognames = ( 143 set(['root']) | logging.root.manager.loggerDict.keys() 144 ) 145 146 # Return True if we contain any nonexistent loggers. Even if 147 # we wouldn't change their level, the fact that we'd create 148 # them still counts as a difference. 149 if any( 150 logname not in existinglognames for logname in self.levels.keys() 151 ): 152 return True 153 154 # Now go through all existing loggers and return True if we 155 # would change their level. 156 for logname in existinglognames: 157 logger = logging.getLogger(logname) 158 level = self.levels.get(logname) 159 if level is None: 160 level = logging.NOTSET 161 if logger.level != level: 162 return True 163 164 return False 165 166 def diff(self, baseconfig: LoggerControlConfig) -> LoggerControlConfig: 167 """Return a config containing only changes compared to a base config. 168 169 Note that this omits all NOTSET values that resolve to NOTSET in 170 the base config. 171 172 This diffed config can later be used with apply_diff() against the 173 base config to recreate the state represented by self. 174 """ 175 cls = type(self) 176 config = cls() 177 for loggername, level in self.levels.items(): 178 baselevel = baseconfig.levels.get(loggername, logging.NOTSET) 179 if level != baselevel: 180 config.levels[loggername] = level 181 return config 182 183 def apply_diff( 184 self, diffconfig: LoggerControlConfig 185 ) -> LoggerControlConfig: 186 """Apply a diff config to ourself. 187 188 Note that values that resolve to NOTSET are left intact in the 189 output config. This is so all loggers expected by either the 190 base or diff config to exist can be created if desired/etc. 191 """ 192 cls = type(self) 193 194 # Create a new config (with an indepenent levels dict copy). 195 config = cls(levels=dict(self.levels)) 196 197 # Overlay the diff levels dict onto our new one. 198 config.levels.update(diffconfig.levels) 199 200 # Note: we do NOT prune NOTSET values here. This is so all 201 # loggers mentioned in the base config get created if we are 202 # applied, even if they are assigned a default level. 203 return config 204 205 @classmethod 206 def from_current_loggers(cls) -> Self: 207 """Build a config from the current set of loggers.""" 208 lognames = ['root'] + sorted(logging.root.manager.loggerDict) 209 config = cls() 210 for logname in lognames: 211 config.levels[logname] = logging.getLogger(logname).level 212 return config
18@ioprepped 19@dataclass 20class LoggerControlConfig: 21 """A logging level configuration that applies to all loggers. 22 23 Any loggers not explicitly contained in the configuration will be 24 set to NOTSET. 25 """ 26 27 # Logger names mapped to log-level values (from system logging 28 # module). 29 levels: Annotated[dict[str, int], IOAttrs('l', store_default=False)] = ( 30 field(default_factory=dict) 31 ) 32 33 def apply( 34 self, 35 *, 36 warn_unexpected_loggers: bool = False, 37 warn_missing_loggers: bool = False, 38 ignore_log_prefixes: list[str] | None = None, 39 ) -> None: 40 """Apply the config to all Python loggers. 41 42 If 'warn_unexpected_loggers' is True, warnings will be issues for 43 any loggers not explicitly covered by the config. This is useful 44 to help ensure controls for all possible loggers are present in 45 a UI/etc. 46 47 If 'warn_missing_loggers' is True, warnings will be issued for 48 any loggers present in the config that are not found at apply time. 49 This can be useful for pruning settings for no longer used loggers. 50 51 Warnings for any log names beginning with any strings in 52 'ignore_log_prefixes' will be suppressed. This can allow 53 ignoring loggers associated with submodules for a given package 54 and instead presenting only a top level logger (or none at all). 55 """ 56 if ignore_log_prefixes is None: 57 ignore_log_prefixes = [] 58 59 existinglognames = ( 60 set(['root']) | logging.root.manager.loggerDict.keys() 61 ) 62 63 # First issue any warnings they want. 64 if warn_unexpected_loggers: 65 for logname in sorted(existinglognames): 66 if logname not in self.levels and not any( 67 logname.startswith(pre) for pre in ignore_log_prefixes 68 ): 69 logging.warning( 70 'Found a logger not covered by LoggerControlConfig:' 71 " '%s'.", 72 logname, 73 ) 74 if warn_missing_loggers: 75 for logname in sorted(self.levels.keys()): 76 if logname not in existinglognames and not any( 77 logname.startswith(pre) for pre in ignore_log_prefixes 78 ): 79 logging.warning( 80 'Logger covered by LoggerControlConfig does not exist:' 81 ' %s.', 82 logname, 83 ) 84 85 # First, update levels for all existing loggers. 86 for logname in existinglognames: 87 logger = logging.getLogger(logname) 88 level = self.levels.get(logname) 89 if level is None: 90 level = logging.NOTSET 91 logger.setLevel(level) 92 93 # Next, assign levels to any loggers that don't exist. 94 for logname, level in self.levels.items(): 95 if logname not in existinglognames: 96 logging.getLogger(logname).setLevel(level) 97 98 def sanity_check_effective_levels(self) -> None: 99 """Checks existing loggers to make sure they line up with us. 100 101 This can be called periodically to ensure that a control-config 102 is properly driving log levels and that nothing else is changing 103 them behind our back. 104 """ 105 106 existinglognames = ( 107 set(['root']) | logging.root.manager.loggerDict.keys() 108 ) 109 for logname in existinglognames: 110 logger = logging.getLogger(logname) 111 if logger.getEffectiveLevel() != self.get_effective_level(logname): 112 logging.error( 113 'loggercontrol effective-level sanity check failed;' 114 ' expected logger %s to have effective level %s' 115 ' but it has %s.', 116 logname, 117 logging.getLevelName(self.get_effective_level(logname)), 118 logging.getLevelName(logger.getEffectiveLevel()), 119 ) 120 121 def get_effective_level(self, logname: str) -> int: 122 """Given a log name, predict its level if this config is applied.""" 123 splits = logname.split('.') 124 125 splen = len(splits) 126 for i in range(splen): 127 subname = '.'.join(splits[: splen - i]) 128 thisval = self.levels.get(subname) 129 if thisval is not None and thisval != logging.NOTSET: 130 return thisval 131 132 # Haven't found anything; just return root value. 133 thisval = self.levels.get('root') 134 return ( 135 logging.DEBUG 136 if thisval is None 137 else logging.DEBUG if thisval == logging.NOTSET else thisval 138 ) 139 140 def would_make_changes(self) -> bool: 141 """Return whether calling apply would change anything.""" 142 143 existinglognames = ( 144 set(['root']) | logging.root.manager.loggerDict.keys() 145 ) 146 147 # Return True if we contain any nonexistent loggers. Even if 148 # we wouldn't change their level, the fact that we'd create 149 # them still counts as a difference. 150 if any( 151 logname not in existinglognames for logname in self.levels.keys() 152 ): 153 return True 154 155 # Now go through all existing loggers and return True if we 156 # would change their level. 157 for logname in existinglognames: 158 logger = logging.getLogger(logname) 159 level = self.levels.get(logname) 160 if level is None: 161 level = logging.NOTSET 162 if logger.level != level: 163 return True 164 165 return False 166 167 def diff(self, baseconfig: LoggerControlConfig) -> LoggerControlConfig: 168 """Return a config containing only changes compared to a base config. 169 170 Note that this omits all NOTSET values that resolve to NOTSET in 171 the base config. 172 173 This diffed config can later be used with apply_diff() against the 174 base config to recreate the state represented by self. 175 """ 176 cls = type(self) 177 config = cls() 178 for loggername, level in self.levels.items(): 179 baselevel = baseconfig.levels.get(loggername, logging.NOTSET) 180 if level != baselevel: 181 config.levels[loggername] = level 182 return config 183 184 def apply_diff( 185 self, diffconfig: LoggerControlConfig 186 ) -> LoggerControlConfig: 187 """Apply a diff config to ourself. 188 189 Note that values that resolve to NOTSET are left intact in the 190 output config. This is so all loggers expected by either the 191 base or diff config to exist can be created if desired/etc. 192 """ 193 cls = type(self) 194 195 # Create a new config (with an indepenent levels dict copy). 196 config = cls(levels=dict(self.levels)) 197 198 # Overlay the diff levels dict onto our new one. 199 config.levels.update(diffconfig.levels) 200 201 # Note: we do NOT prune NOTSET values here. This is so all 202 # loggers mentioned in the base config get created if we are 203 # applied, even if they are assigned a default level. 204 return config 205 206 @classmethod 207 def from_current_loggers(cls) -> Self: 208 """Build a config from the current set of loggers.""" 209 lognames = ['root'] + sorted(logging.root.manager.loggerDict) 210 config = cls() 211 for logname in lognames: 212 config.levels[logname] = logging.getLogger(logname).level 213 return config
A logging level configuration that applies to all loggers.
Any loggers not explicitly contained in the configuration will be set to NOTSET.
33 def apply( 34 self, 35 *, 36 warn_unexpected_loggers: bool = False, 37 warn_missing_loggers: bool = False, 38 ignore_log_prefixes: list[str] | None = None, 39 ) -> None: 40 """Apply the config to all Python loggers. 41 42 If 'warn_unexpected_loggers' is True, warnings will be issues for 43 any loggers not explicitly covered by the config. This is useful 44 to help ensure controls for all possible loggers are present in 45 a UI/etc. 46 47 If 'warn_missing_loggers' is True, warnings will be issued for 48 any loggers present in the config that are not found at apply time. 49 This can be useful for pruning settings for no longer used loggers. 50 51 Warnings for any log names beginning with any strings in 52 'ignore_log_prefixes' will be suppressed. This can allow 53 ignoring loggers associated with submodules for a given package 54 and instead presenting only a top level logger (or none at all). 55 """ 56 if ignore_log_prefixes is None: 57 ignore_log_prefixes = [] 58 59 existinglognames = ( 60 set(['root']) | logging.root.manager.loggerDict.keys() 61 ) 62 63 # First issue any warnings they want. 64 if warn_unexpected_loggers: 65 for logname in sorted(existinglognames): 66 if logname not in self.levels and not any( 67 logname.startswith(pre) for pre in ignore_log_prefixes 68 ): 69 logging.warning( 70 'Found a logger not covered by LoggerControlConfig:' 71 " '%s'.", 72 logname, 73 ) 74 if warn_missing_loggers: 75 for logname in sorted(self.levels.keys()): 76 if logname not in existinglognames and not any( 77 logname.startswith(pre) for pre in ignore_log_prefixes 78 ): 79 logging.warning( 80 'Logger covered by LoggerControlConfig does not exist:' 81 ' %s.', 82 logname, 83 ) 84 85 # First, update levels for all existing loggers. 86 for logname in existinglognames: 87 logger = logging.getLogger(logname) 88 level = self.levels.get(logname) 89 if level is None: 90 level = logging.NOTSET 91 logger.setLevel(level) 92 93 # Next, assign levels to any loggers that don't exist. 94 for logname, level in self.levels.items(): 95 if logname not in existinglognames: 96 logging.getLogger(logname).setLevel(level)
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).
98 def sanity_check_effective_levels(self) -> None: 99 """Checks existing loggers to make sure they line up with us. 100 101 This can be called periodically to ensure that a control-config 102 is properly driving log levels and that nothing else is changing 103 them behind our back. 104 """ 105 106 existinglognames = ( 107 set(['root']) | logging.root.manager.loggerDict.keys() 108 ) 109 for logname in existinglognames: 110 logger = logging.getLogger(logname) 111 if logger.getEffectiveLevel() != self.get_effective_level(logname): 112 logging.error( 113 'loggercontrol effective-level sanity check failed;' 114 ' expected logger %s to have effective level %s' 115 ' but it has %s.', 116 logname, 117 logging.getLevelName(self.get_effective_level(logname)), 118 logging.getLevelName(logger.getEffectiveLevel()), 119 )
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.
121 def get_effective_level(self, logname: str) -> int: 122 """Given a log name, predict its level if this config is applied.""" 123 splits = logname.split('.') 124 125 splen = len(splits) 126 for i in range(splen): 127 subname = '.'.join(splits[: splen - i]) 128 thisval = self.levels.get(subname) 129 if thisval is not None and thisval != logging.NOTSET: 130 return thisval 131 132 # Haven't found anything; just return root value. 133 thisval = self.levels.get('root') 134 return ( 135 logging.DEBUG 136 if thisval is None 137 else logging.DEBUG if thisval == logging.NOTSET else thisval 138 )
Given a log name, predict its level if this config is applied.
140 def would_make_changes(self) -> bool: 141 """Return whether calling apply would change anything.""" 142 143 existinglognames = ( 144 set(['root']) | logging.root.manager.loggerDict.keys() 145 ) 146 147 # Return True if we contain any nonexistent loggers. Even if 148 # we wouldn't change their level, the fact that we'd create 149 # them still counts as a difference. 150 if any( 151 logname not in existinglognames for logname in self.levels.keys() 152 ): 153 return True 154 155 # Now go through all existing loggers and return True if we 156 # would change their level. 157 for logname in existinglognames: 158 logger = logging.getLogger(logname) 159 level = self.levels.get(logname) 160 if level is None: 161 level = logging.NOTSET 162 if logger.level != level: 163 return True 164 165 return False
Return whether calling apply would change anything.
167 def diff(self, baseconfig: LoggerControlConfig) -> LoggerControlConfig: 168 """Return a config containing only changes compared to a base config. 169 170 Note that this omits all NOTSET values that resolve to NOTSET in 171 the base config. 172 173 This diffed config can later be used with apply_diff() against the 174 base config to recreate the state represented by self. 175 """ 176 cls = type(self) 177 config = cls() 178 for loggername, level in self.levels.items(): 179 baselevel = baseconfig.levels.get(loggername, logging.NOTSET) 180 if level != baselevel: 181 config.levels[loggername] = level 182 return config
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.
184 def apply_diff( 185 self, diffconfig: LoggerControlConfig 186 ) -> LoggerControlConfig: 187 """Apply a diff config to ourself. 188 189 Note that values that resolve to NOTSET are left intact in the 190 output config. This is so all loggers expected by either the 191 base or diff config to exist can be created if desired/etc. 192 """ 193 cls = type(self) 194 195 # Create a new config (with an indepenent levels dict copy). 196 config = cls(levels=dict(self.levels)) 197 198 # Overlay the diff levels dict onto our new one. 199 config.levels.update(diffconfig.levels) 200 201 # Note: we do NOT prune NOTSET values here. This is so all 202 # loggers mentioned in the base config get created if we are 203 # applied, even if they are assigned a default level. 204 return config
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.
206 @classmethod 207 def from_current_loggers(cls) -> Self: 208 """Build a config from the current set of loggers.""" 209 lognames = ['root'] + sorted(logging.root.manager.loggerDict) 210 config = cls() 211 for logname in lognames: 212 config.levels[logname] = logging.getLogger(logname).level 213 return config
Build a config from the current set of loggers.