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
@ioprepped
@dataclass
class LoggerControlConfig:
 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.

LoggerControlConfig( levels: Annotated[dict[str, int], <efro.dataclassio.IOAttrs object>] = <factory>)
levels: Annotated[dict[str, int], <efro.dataclassio.IOAttrs object at 0x107274800>]
def apply( self, *, warn_unexpected_loggers: bool = False, warn_missing_loggers: bool = False, ignore_log_prefixes: list[str] | None = None) -> None:
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).

def sanity_check_effective_levels(self) -> None:
 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.

def get_effective_level(self, logname: str) -> int:
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.

def would_make_changes(self) -> bool:
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.

def diff( self, baseconfig: LoggerControlConfig) -> LoggerControlConfig:
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.

def apply_diff( self, diffconfig: LoggerControlConfig) -> LoggerControlConfig:
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.

@classmethod
def from_current_loggers(cls) -> Self:
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.