Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,13 @@ Logurich registers shutdown hooks for short-lived programs; call
`shutdown_logger()` when deterministic teardown is needed in tests or before
reconfiguration.

`init_logger()` takes over the root logger: `clear_handlers=True` (the default)
drops handlers installed by a host runtime — AWS Lambda, gunicorn, an APM agent
— so Logurich is the single sink. Pass `clear_handlers=False` to leave them
attached; they then also receive Logurich records, whose `context`,
`renderables` and `rich_traceback` attributes carry Rich objects a foreign
formatter may fail on.

Users upgrading from 0.9 should read the [v1 migration guide](docs/migration-v1.md).

## Development
Expand Down
16 changes: 16 additions & 0 deletions docs/migration-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,22 @@ The matching parameter renamed too: `logurich.opt_click.LOGGER_PARAM_NAMES` now
ends with `logger_console`, and `click_logger_init()` takes
`logger_console: str` instead of `logger_rich: bool`.

## Root handler ownership

As in 0.9, `init_logger()` clears the root logger before installing its own
handlers. 1.0.0 briefly kept foreign handlers attached, which double-wrote every
record and let a host formatter choke on the Rich objects Logurich attaches to a
`LogRecord` (`context`, `renderables`, `rich_traceback`) — most visibly under AWS
Lambda with `AWS_LAMBDA_LOG_FORMAT=JSON`, where the `TypeError` surfaces at the
`logger.info()` call site.

The behaviour is now explicit: opt out with `clear_handlers=False` when the host
runtime's handler must keep receiving records.

```python
init_logger("INFO", clear_handlers=False)
```

## Module layout

Context helpers now live in `logurich.context` instead of `logurich.core`, so
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "logurich"
version = "1.0.0"
version = "1.0.1"
description = "A Python library combining standard logging and Rich for beautiful logging."
authors = [
{ name = "PakitoSec", email = "jeromep83@gmail.com" }
Expand Down
2 changes: 1 addition & 1 deletion src/logurich/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Public package exports for logurich."""

__version__ = "1.0.0"
__version__ = "1.0.1"

from .console import (
console,
Expand Down
26 changes: 26 additions & 0 deletions src/logurich/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,23 @@ def _remove_installed_handlers() -> list[logging.Handler]:
return handlers


def _clear_root_handlers() -> None:
"""Drop every root handler so Logurich becomes the single sink.

Handlers Logurich built (they all carry ``_OUTPUT_FILTER``) are closed;
foreign ones are only detached, since the host runtime owns their resources
— closing them could take down a file descriptor it still writes to.
"""

root = logging.getLogger()
superseded: list[logging.Handler] = []
for handler in list(root.handlers):
root.removeHandler(handler)
if _OUTPUT_FILTER in handler.filters:
superseded.append(handler)
_close_handlers(superseded)


def _configure_handler(handler: logging.Handler, *, producer: bool) -> None:
handler.setLevel(logging.NOTSET)
handler.addFilter(_OUTPUT_FILTER)
Expand Down Expand Up @@ -843,11 +860,17 @@ def init_logger(
rotation: Optional[Union[str, int]] = "12:00",
retention: Optional[int] = 10,
force: bool = False,
clear_handlers: bool = True,
) -> Optional[str]:
"""Configure stdlib logging with independent console and file formats.

``LOGURICH_OUTPUT``, when set, takes precedence over ``console`` and does
not affect ``file``.

``clear_handlers`` drops every root handler, so Logurich becomes the single
sink. Set it to ``False`` to leave handlers installed by a host runtime (AWS
Lambda, gunicorn, an APM agent) in place — they then also receive Logurich
records, which carry Rich objects a foreign formatter may not handle.
"""

_warn_legacy_environment(os.environ)
Expand Down Expand Up @@ -875,6 +898,9 @@ def init_logger(
# _OUTPUT_FILTER still applies the exact per-module level at the handler.
root.setLevel(_level_floor(min_level, module_levels))

if clear_handlers:
_clear_root_handlers()

logger_state.update(
{
"min_level": min_level,
Expand Down
57 changes: 57 additions & 0 deletions tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,63 @@ def test_init_is_idempotent_and_force_reconfigures(buffer):
assert "now visible" in output


def test_clear_handlers_detaches_foreign_handlers():
foreign = logging.NullHandler()
root = logging.getLogger()
root.addHandler(foreign)
try:
init_logger("INFO", enqueue=False)
assert foreign not in root.handlers
shutdown_logger()
finally:
root.removeHandler(foreign)


def test_clear_handlers_false_keeps_foreign_handlers():
foreign = logging.NullHandler()
root = logging.getLogger()
root.addHandler(foreign)
try:
init_logger("INFO", enqueue=False, clear_handlers=False)
assert foreign in root.handlers
shutdown_logger()
finally:
root.removeHandler(foreign)


def test_clear_handlers_drops_untracked_logurich_handlers(monkeypatch):
root = logging.getLogger()
init_logger("INFO", enqueue=False)
stale = root.handlers[0]
monkeypatch.setitem(logger_state, "installed_handlers", ())

init_logger("INFO", enqueue=False, force=True)
assert stale not in root.handlers

shutdown_logger()
assert stale not in root.handlers


def test_foreign_handler_sees_no_record_when_cleared():
"""A foreign formatter must not choke on the Rich objects Logurich attaches."""
seen: list[logging.LogRecord] = []

class Probe(logging.Handler):
def emit(self, record):
seen.append(record)

probe = Probe()
root = logging.getLogger()
root.addHandler(probe)
try:
init_logger("INFO", enqueue=False)
get_logger("tests.foreign").info("hello", request=ctx("REQ-1"))
shutdown_logger()
assert seen == []
finally:
root.removeHandler(probe)


def test_context_is_isolated_from_new_thread(logger, buffer):
with global_context(request="main"):
thread = threading.Thread(target=lambda: logger.info("thread"))
Expand Down
Loading