Skip to content

Commit f00ad7e

Browse files
authored
Merge pull request #6 from Terseus/feature/autoinit_global_context
Feature/autoinit global context
2 parents ec05e18 + d4c1828 commit f00ad7e

File tree

4 files changed

+96
-9
lines changed

4 files changed

+96
-9
lines changed

docs/howto.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,27 @@ with add_global_context({"user_id": user_id, "request_id": request_id}):
4545
```
4646

4747

48+
## Using the automatic init
49+
50+
If you don't want to customise the initialization you can let `add_global_context` automatically handle the init and shutdown for you:
51+
52+
```python
53+
import logging
54+
55+
from logging_with_context.global_context import add_global_context
56+
57+
58+
def main():
59+
logging.basicConfig(level=logging.INFO) # Or any other way to setup logging.
60+
with add_global_context({"user_id": 10}):
61+
# Here the context is automatically initialized.
62+
# It'll also be automatically shutdown once this context manager finishes.
63+
```
64+
65+
4866
## Using the init/shutdown API
4967

50-
In case you can't use the context manager, you can use the manual initialization and shutdown API:
68+
In case you want to customise the initialization but can't use the context manager, you can use the manual initialization and shutdown API:
5169

5270
```python
5371
import logging

run_tests.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
#!/usr/bin/env bash
22

33
for python in 3.9 3.10 3.11 3.12 3.13; do
4-
uv run --locked --isolated --python=$python pytest
4+
uv run --frozen --isolated --python=$python pytest
55
done

src/logging_with_context/global_context.py

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@
1616
__global_context_var: ContextVar[dict[str, Any]] = ContextVar(
1717
"global_context", default={}
1818
)
19+
__global_context_initialized: ContextVar[bool] = ContextVar(
20+
"global_context_initialized", default=False
21+
)
22+
23+
24+
def _get_loggers_to_process(loggers: Optional[Sequence[Logger]] = None) -> list[Logger]:
25+
return [getLogger()] if loggers is None else list(loggers)
1926

2027

2128
def init_global_context(loggers: Optional[Sequence[Logger]] = None) -> None:
@@ -26,9 +33,9 @@ def init_global_context(loggers: Optional[Sequence[Logger]] = None) -> None:
2633
loggers: The loggers to attach the global context; if not loggers are specified
2734
it will use the root logger.
2835
"""
29-
loggers_to_process = [getLogger()] if loggers is None else list(loggers)
36+
__global_context_initialized.set(True)
3037
filter_with_context = FilterWithContextVar(__global_context_var)
31-
for logger in loggers_to_process:
38+
for logger in _get_loggers_to_process(loggers):
3239
for handler in logger.handlers:
3340
handler.addFilter(filter_with_context)
3441

@@ -41,8 +48,8 @@ def shutdown_global_context(loggers: Optional[Sequence[Logger]] = None) -> None:
4148
loggers: The loggers that were used when calling `init_global_context`; by
4249
default the root logger.
4350
"""
44-
loggers_to_process = [getLogger()] if loggers is None else list(loggers)
45-
for logger in loggers_to_process:
51+
__global_context_initialized.set(False)
52+
for logger in _get_loggers_to_process(loggers):
4653
for handler in logger.handlers:
4754
for filter_ in handler.filters:
4855
if not isinstance(filter_, FilterWithContextVar):
@@ -72,20 +79,36 @@ def global_context_initialized(
7279

7380

7481
@contextmanager
75-
def add_global_context(context: dict[str, Any]) -> Generator[None, None, None]:
82+
def add_global_context(
83+
context: dict[str, Any], *, auto_init: bool = True
84+
) -> Generator[None, None, None]:
7685
"""
7786
Add values to the global context to be attached to all the log messages.
7887
7988
The values will be removed from the global context once the context manager exists.
8089
8190
Parameters:
8291
context: A key/value mapping with the values to add to the global context.
92+
auto_init: Indicate if the global context should be automatically initialized
93+
if it isn't.
94+
95+
If `True`, the context will be also automatically shutdown before exiting.
96+
97+
If the global context is already initialized it'll do nothing.
98+
99+
Keyword-only argument.
83100
84101
Returns:
85102
A context manager that manages the life of the values.
86103
"""
104+
auto_initialized = False
105+
if not __global_context_initialized.get() and auto_init:
106+
init_global_context()
107+
auto_initialized = True
87108
token = __global_context_var.set(__global_context_var.get() | context)
88109
try:
89110
yield
90111
finally:
91112
__global_context_var.reset(token)
113+
if auto_initialized:
114+
shutdown_global_context()

tests/logging_with_context/test_global_context.py

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
import threading
23

34
import pytest
45

@@ -23,7 +24,7 @@ def test_add_global_context_ok(caplog: pytest.LogCaptureFixture):
2324

2425
def test_add_global_context_without_init_ignored_ok(caplog: pytest.LogCaptureFixture):
2526
logger = logging.getLogger(__name__)
26-
with add_global_context({"key": "value"}):
27+
with add_global_context({"key": "value"}, auto_init=False):
2728
with caplog.at_level(logging.INFO):
2829
logger.info("Test message")
2930
assert len(caplog.records) == 1
@@ -35,8 +36,53 @@ def test_add_global_context_after_shutdown_ignored_ok(caplog: pytest.LogCaptureF
3536
logger = logging.getLogger(__name__)
3637
with global_context_initialized():
3738
pass
38-
with add_global_context({"key": "value"}), caplog.at_level(logging.INFO):
39+
with (
40+
add_global_context({"key": "value"}, auto_init=False),
41+
caplog.at_level(logging.INFO),
42+
):
3943
logger.info("Test message")
4044
assert len(caplog.records) == 1
4145
result = caplog.records[0]
4246
assert not hasattr(result, "key")
47+
48+
49+
def test_add_global_context_auto_init_ok(caplog: pytest.LogCaptureFixture):
50+
logger = logging.getLogger(__name__)
51+
with add_global_context({"key": "value"}), caplog.at_level(logging.INFO):
52+
logger.info("Test message")
53+
assert len(caplog.records) == 1
54+
result = caplog.records[0]
55+
assert result.key == "value" # type: ignore
56+
57+
58+
def test_add_global_context_multithread(caplog: pytest.LogCaptureFixture):
59+
def worker(value: int) -> None:
60+
with add_global_context({"value": value}):
61+
logger.info("Message 1 from thread %s", value)
62+
with add_global_context({"value": value * 10}):
63+
logger.info("Message 2 from thread %s", value)
64+
with add_global_context({"value": value * 100}):
65+
logger.info("Message 3 from thread %s", value)
66+
67+
logger = logging.getLogger(__name__)
68+
with global_context_initialized(), caplog.at_level(logging.INFO):
69+
worker_1 = threading.Thread(target=worker, args=(1,))
70+
worker_2 = threading.Thread(target=worker, args=(2,))
71+
worker_1.start()
72+
worker_2.start()
73+
worker_1.join()
74+
worker_2.join()
75+
assert len(caplog.records) == 6
76+
result = [
77+
{"message": record.message, "value": record.value} # type: ignore
78+
for record in sorted(caplog.records, key=lambda r: r.value) # type: ignore
79+
]
80+
expected = [
81+
{"message": "Message 1 from thread 1", "value": 1},
82+
{"message": "Message 1 from thread 2", "value": 2},
83+
{"message": "Message 2 from thread 1", "value": 10},
84+
{"message": "Message 2 from thread 2", "value": 20},
85+
{"message": "Message 3 from thread 1", "value": 100},
86+
{"message": "Message 3 from thread 2", "value": 200},
87+
]
88+
assert result == expected

0 commit comments

Comments
 (0)