Skip to content

Commit cd2da8f

Browse files
committed
feat(porting): add state porting when replacing loggers
- Add port_state parameter to getLogger/getLoggerOfType - Implement handler and level porting functionality - Add registry support for porting operations - Add comprehensive tests for porting scenarios - Update API documentation
1 parent 9df6b36 commit cd2da8f

File tree

14 files changed

+2140
-1146
lines changed

14 files changed

+2140
-1146
lines changed

ROADMAP.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,14 @@ Some of these we just want to consider, and may not want to implement.
2323

2424
## 🔌 API
2525
- Evaluate `_applyPropagateSetting()` and its relationship with `__init__`: Currently, `Logger.__init__()` sets `_propagate_set = False` when `propagate=None`, indicating that `_applyPropagateSetting()` will set it later. This creates a two-phase initialization where propagate can be set either in `__init__` or later via `_applyPropagateSetting()`. Evaluate whether this split responsibility is clear and maintainable, or if propagate should always be set in `__init__` with the registry/default value passed directly. Consider the complexity of tracking `_propagate_set` flag and whether there are edge cases where the propagate value might be inconsistent or set at unexpected times.
26-
- **Root logger replacement: porting handlers and level:** When `extendLoggingModule()` replaces the root logger with an apathetic logger, we currently preserve the old root logger's level, handlers, propagate, and disabled state. Evaluate whether this behavior should be configurable:
27-
- Should we port handlers by default, or should the new apathetic root logger start fresh with its own handlers (via `manageHandlers()`)?
28-
- Should we port the level by default, or should the new root logger use a default level (NOTSET/INHERIT)?
29-
- If we make this configurable, do we need keyword arguments to `extendLoggingModule()`, registry settings, or constants for these scenarios?
30-
- Should users be able to specify they want handlers/level ported, or does porting them not make good sense at all (e.g., because apathetic loggers should manage their own handlers)?
31-
- Consider edge cases: What if the old root logger has incompatible handlers? What if the level was set to a custom value that doesn't exist in apathetic logging?
26+
- **Root logger replacement: edge cases with custom logger classes:** Review and test situations where we replace the root logger, especially when the default logger class (set via `logging.setLoggerClass()`) is:
27+
- Not a stdlib `logging.Logger` (e.g., a completely different class)
28+
- A different subclass of `logging.Logger` (e.g., a third-party logger class)
29+
- A subclass of our apathetic logger (e.g., `class MyLogger(apathetic_logging.Logger): pass`)
30+
- Verify that our `isinstance(root_logger, cls)` check in `extendLoggingModule()` correctly identifies when replacement is needed
31+
- Ensure state porting works correctly when replacing loggers of different types
32+
- Test behavior when `replace_root=False` is set but the root logger is a different type
33+
- Consider whether we should warn or error when replacing a logger that's a subclass of our apathetic logger (might indicate user wants to use their subclass)
3234

3335

3436
## 📚 Documentation

docs/api.md

Lines changed: 1258 additions & 1120 deletions
Large diffs are not rendered by default.

src/apathetic_logging/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@
121121
registerLogLevelEnvVars = apathetic_logging.registerLogLevelEnvVars
122122
registerLogger = apathetic_logging.registerLogger
123123
registerCompatibilityMode = apathetic_logging.registerCompatibilityMode
124+
registerPortHandlers = apathetic_logging.registerPortHandlers
125+
registerPortLevel = apathetic_logging.registerPortLevel
124126
registerPropagate = apathetic_logging.registerPropagate
125127
registerReplaceRootLogger = apathetic_logging.registerReplaceRootLogger
126128
registerTargetPythonVersion = apathetic_logging.registerTargetPythonVersion
@@ -187,6 +189,8 @@
187189
"registerDefaultLogLevel",
188190
"registerLogLevelEnvVars",
189191
"registerLogger",
192+
"registerPortHandlers",
193+
"registerPortLevel",
190194
"registerPropagate",
191195
"registerReplaceRootLogger",
192196
"registerTargetPythonVersion",

src/apathetic_logging/constants.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,3 +152,21 @@ class ANSIColors:
152152
When False, the root logger will not be replaced, allowing applications
153153
to use their own custom logger class for the root logger.
154154
"""
155+
156+
DEFAULT_PORT_HANDLERS: bool = True
157+
"""Default value for whether to port handlers when replacing a logger.
158+
159+
When True (default), handlers from the old logger are ported to the new logger,
160+
preserving existing configuration. When False, the new apathetic logger manages
161+
its own handlers via manageHandlers() (may conflict with ported handlers).
162+
"""
163+
164+
DEFAULT_PORT_LEVEL: bool = True
165+
"""Default value for whether to port level when replacing a logger.
166+
167+
When True (default), the log level is ported from the old logger to the new
168+
logger, preserving existing configuration. When False, the new logger uses
169+
apathetic defaults (determineLogLevel() for root logger, INHERIT_LEVEL for
170+
leaf loggers). Note: User-provided level parameters in getLogger/getLoggerOfType
171+
take precedence over ported level.
172+
"""

src/apathetic_logging/get_logger.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,9 +114,21 @@ def _getOrCreateLoggerOfType(
114114
# Save the parent that was assigned
115115
old_parent = logger.parent
116116

117-
# Reconnect child loggers if we replaced an existing logger
117+
# Port state from old logger if we replaced an existing logger
118+
# (also reconnects child loggers internally)
118119
if old_logger is not None:
119-
_logging_utils.reconnectChildLoggers(old_logger, logger)
120+
# Port state from old logger, but user-provided kwargs take precedence
121+
# Check if level is explicitly provided in kwargs - if so, don't port
122+
# level (user's level will be applied later in getLoggerOfType)
123+
user_provided_level = kwargs.get("level")
124+
# Only port if user didn't provide level
125+
port_level = user_provided_level is None
126+
_logging_utils.portLoggerState(
127+
old_logger,
128+
logger,
129+
port_handlers=None, # Use default (True) - port handlers
130+
port_level=port_level, # Port level only if user didn't provide one
131+
)
120132

121133
# Fix parent if it points to old root logger
122134
# Only fix if this is not the root logger itself

src/apathetic_logging/logger.py

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,8 @@ def extendLoggingModule(
656656
cls,
657657
*,
658658
replace_root: bool | None = None,
659+
port_handlers: bool | None = None,
660+
port_level: bool | None = None,
659661
) -> bool:
660662
"""The return value tells you if we ran or not.
661663
If it is False and you're calling it via super(),
@@ -668,6 +670,17 @@ def extendLoggingModule(
668670
for backward compatibility. When False, the root logger will not be
669671
replaced, allowing applications to use their own custom logger class
670672
for the root logger.
673+
port_handlers: Whether to port handlers from the old root logger to the
674+
new logger. If None (default), checks the registry setting (set via
675+
registerPortHandlers()). If not set in registry, defaults to True
676+
(DEFAULT_PORT_HANDLERS from constants.py). When False, the new
677+
logger manages its own handlers via manageHandlers().
678+
port_level: Whether to port level from the old root logger to the new
679+
logger. If None (default), checks the registry setting (set via
680+
registerPortLevel()). If not set in registry, defaults to True
681+
(DEFAULT_PORT_LEVEL from constants.py). When False, the new root
682+
logger uses determineLogLevel() to get a sensible default. When
683+
True, the old level is preserved.
671684
672685
Note for tests:
673686
When testing isinstance checks on logger instances, use
@@ -727,12 +740,6 @@ def extendLoggingModule(
727740
# becomes wrong type later (e.g., in tests that create standard root logger)
728741
if replace_root and (not already_extended or not isinstance(root_logger, cls)):
729742
# Root logger is wrong type - need to replace it
730-
# Save state from old root logger
731-
old_level = root_logger.level
732-
old_handlers = list(root_logger.handlers) # Copy list
733-
old_propagate = root_logger.propagate
734-
old_disabled = root_logger.disabled
735-
736743
# Remove old root logger from registry
737744
from .logging_utils import ( # noqa: PLC0415
738745
ApatheticLogging_Internal_LoggingUtils,
@@ -776,17 +783,14 @@ def extendLoggingModule(
776783
if hasattr(logging, "root"):
777784
logging.root = new_root_logger # type: ignore[assignment]
778785

779-
# Restore state from old root logger
780-
new_root_logger.setLevel(old_level)
781-
new_root_logger.propagate = old_propagate
782-
new_root_logger.disabled = old_disabled
783-
# Restore handlers (they were attached to old logger, so we need to
784-
# re-add them)
785-
for handler in old_handlers:
786-
new_root_logger.addHandler(handler)
787-
788-
# Reconnect child loggers to the new root logger
789-
_logging_utils.reconnectChildLoggers(root_logger, new_root_logger)
786+
# Port state from old root logger to new root logger
787+
# (also reconnects child loggers internally)
788+
_logging_utils.portLoggerState(
789+
root_logger,
790+
new_root_logger,
791+
port_handlers=port_handlers,
792+
port_level=port_level,
793+
)
790794

791795
# If already extended, skip the rest (level registration, etc.)
792796
if already_extended:

src/apathetic_logging/logging_utils.py

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,187 @@ def reconnectChildLoggers(
325325
if is_child and logger.parent is old_logger:
326326
logger.parent = new_logger
327327

328+
@staticmethod
329+
def _portPropagateAndDisabled(
330+
old_logger: logging.Logger,
331+
new_logger: logging.Logger,
332+
) -> None:
333+
"""Port propagate and disabled state from old logger to new logger."""
334+
# Use setPropagate() if available to set the _propagate_set flag
335+
# (prevents _applyPropagateSetting() from overriding ported value)
336+
if hasattr(new_logger, "setPropagate"):
337+
new_logger.setPropagate(old_logger.propagate) # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType]
338+
else:
339+
new_logger.propagate = old_logger.propagate
340+
new_logger.disabled = old_logger.disabled
341+
342+
@staticmethod
343+
def _portHandlers(
344+
old_logger: logging.Logger,
345+
new_logger: logging.Logger,
346+
) -> None:
347+
"""Port handlers from old logger to new logger."""
348+
old_handlers = list(old_logger.handlers) # Copy list
349+
for handler in old_handlers:
350+
new_logger.addHandler(handler)
351+
352+
@staticmethod
353+
def _portLevel(
354+
old_logger: logging.Logger,
355+
new_logger: logging.Logger,
356+
*,
357+
constants: Any,
358+
) -> None:
359+
"""Port level from old logger to new logger."""
360+
old_level = old_logger.level
361+
# Validate level if it's an apathetic logger (has validateLevel)
362+
if hasattr(new_logger, "validateLevel"):
363+
try:
364+
new_logger.validateLevel(old_level, allow_inherit=True) # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType]
365+
except ValueError:
366+
# Invalid level - fall back to apathetic default
367+
return
368+
369+
# Use allow_inherit=True if level is INHERIT_LEVEL
370+
if old_level == constants.INHERIT_LEVEL:
371+
if hasattr(new_logger, "setLevel"):
372+
sig = inspect.signature(new_logger.setLevel)
373+
if "allow_inherit" in sig.parameters:
374+
new_logger.setLevel(old_level, allow_inherit=True) # type: ignore[call-arg]
375+
else:
376+
new_logger.setLevel(old_level)
377+
else:
378+
new_logger.level = old_level
379+
else:
380+
new_logger.setLevel(old_level)
381+
382+
@staticmethod
383+
def _setApatheticDefaults(
384+
new_logger: logging.Logger,
385+
*,
386+
constants: Any,
387+
) -> None:
388+
"""Set apathetic defaults for logger level."""
389+
root_names = {constants.ROOT_LOGGER_KEY, constants.ROOT_LOGGER_NAME}
390+
is_root = new_logger.name in root_names
391+
if is_root:
392+
# Root logger: use determineLogLevel() if available
393+
if hasattr(new_logger, "determineLogLevel"):
394+
level_name = new_logger.determineLogLevel() # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType, reportUnknownVariableType]
395+
new_logger.setLevel(level_name) # pyright: ignore[reportUnknownArgumentType]
396+
else:
397+
# Fallback: use INHERIT_LEVEL (though root has no parent)
398+
new_logger.setLevel(constants.INHERIT_LEVEL, allow_inherit=True) # type: ignore[call-arg]
399+
# Leaf logger: use INHERIT_LEVEL to inherit from parent
400+
elif hasattr(new_logger, "setLevel"):
401+
sig = inspect.signature(new_logger.setLevel)
402+
if "allow_inherit" in sig.parameters:
403+
new_logger.setLevel(
404+
constants.INHERIT_LEVEL,
405+
allow_inherit=True, # type: ignore[call-arg]
406+
)
407+
else:
408+
new_logger.setLevel(constants.INHERIT_LEVEL)
409+
else:
410+
new_logger.level = constants.INHERIT_LEVEL
411+
412+
@staticmethod
413+
def portLoggerState(
414+
old_logger: logging.Logger,
415+
new_logger: logging.Logger,
416+
*,
417+
port_handlers: bool | None = None,
418+
port_level: bool | None = None,
419+
) -> None:
420+
"""Port state from old logger to new logger.
421+
422+
Ports propagate and disabled state always. Optionally ports handlers
423+
and level based on parameters. When not porting level, uses apathetic
424+
defaults: determineLogLevel() for root logger, INHERIT_LEVEL for leaf loggers.
425+
426+
After porting (or not porting) handlers, calls manageHandlers() if the new
427+
logger supports it, to ensure apathetic handlers are set up appropriately
428+
based on propagate setting. This ensures root logger always has a handler,
429+
and child loggers with propagate=False get handlers as needed. manageHandlers()
430+
only manages DualStreamHandler instances, so it won't interfere with ported
431+
user handlers.
432+
433+
Finally, reconnects child loggers from the old logger to the new logger,
434+
ensuring child loggers point to the new logger instance after replacement.
435+
436+
Args:
437+
old_logger: The logger being replaced.
438+
new_logger: The new logger to port state to.
439+
port_handlers: Whether to port handlers. If None, checks registry setting
440+
or defaults to True. When True, handlers from old logger are ported.
441+
When False, new logger manages its own handlers via manageHandlers().
442+
In both cases, manageHandlers() is called to ensure apathetic handlers
443+
are set up if needed.
444+
port_level: Whether to port level. If None, checks registry setting or
445+
defaults to True. When True, level from old logger is ported.
446+
When False, uses apathetic defaults (determineLogLevel() for root,
447+
INHERIT_LEVEL for leaf loggers). Note: User-provided level parameters
448+
in getLogger/getLoggerOfType take precedence over ported level.
449+
"""
450+
from .constants import ( # noqa: PLC0415
451+
ApatheticLogging_Internal_Constants,
452+
)
453+
from .registry_data import ( # noqa: PLC0415
454+
ApatheticLogging_Internal_RegistryData,
455+
)
456+
457+
_constants = ApatheticLogging_Internal_Constants
458+
_registry_data = ApatheticLogging_Internal_RegistryData
459+
460+
# Always port propagate and disabled
461+
ApatheticLogging_Internal_LoggingUtils._portPropagateAndDisabled(
462+
old_logger, new_logger
463+
)
464+
465+
# Resolve port_handlers parameter
466+
if port_handlers is None:
467+
port_handlers = (
468+
_registry_data.registered_internal_port_handlers
469+
if _registry_data.registered_internal_port_handlers is not None
470+
else _constants.DEFAULT_PORT_HANDLERS
471+
)
472+
473+
# Port handlers if requested
474+
if port_handlers:
475+
ApatheticLogging_Internal_LoggingUtils._portHandlers(old_logger, new_logger)
476+
477+
# After porting (or not porting) handlers, ensure apathetic handlers are set up
478+
# if the logger supports manageHandlers(). This ensures root logger always has
479+
# a handler, and child loggers with propagate=False get handlers as needed.
480+
# manageHandlers() only manages DualStreamHandler instances, so it won't
481+
# interfere with ported user handlers.
482+
if hasattr(new_logger, "manageHandlers"):
483+
new_logger.manageHandlers() # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType]
484+
485+
# Resolve port_level parameter
486+
if port_level is None:
487+
port_level = (
488+
_registry_data.registered_internal_port_level
489+
if _registry_data.registered_internal_port_level is not None
490+
else _constants.DEFAULT_PORT_LEVEL
491+
)
492+
493+
# Port level if requested, otherwise use apathetic defaults
494+
if port_level:
495+
ApatheticLogging_Internal_LoggingUtils._portLevel(
496+
old_logger, new_logger, constants=_constants
497+
)
498+
else:
499+
ApatheticLogging_Internal_LoggingUtils._setApatheticDefaults(
500+
new_logger, constants=_constants
501+
)
502+
503+
# Reconnect child loggers from old logger to new logger
504+
# This ensures child loggers point to the new logger instance after replacement
505+
ApatheticLogging_Internal_LoggingUtils.reconnectChildLoggers(
506+
old_logger, new_logger
507+
)
508+
328509
@staticmethod
329510
def removeLogger(logger_name: str) -> None:
330511
"""Remove a logger from the logging manager's registry.

0 commit comments

Comments
 (0)