Skip to content

Commit 13f305d

Browse files
Copilotbittner
authored andcommitted
Add logging isolation for Pytest compatibility
1 parent cf09c2d commit 13f305d

File tree

3 files changed

+178
-4
lines changed

3 files changed

+178
-4
lines changed

cli_test_helpers/decorators.py

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"""
44

55
import contextlib
6+
import logging
67
import os
78
import sys
89
from tempfile import TemporaryDirectory
@@ -11,7 +12,36 @@
1112
__all__ = []
1213

1314

14-
class ArgvContext:
15+
class LoggingIsolationMixin:
16+
"""
17+
Mixin that provides automatic logging configuration isolation.
18+
19+
Isolates logging configuration by temporarily clearing logging handlers
20+
when entering the context and restoring them when exiting. This allows
21+
code under test to call ``logging.basicConfig()`` successfully even when
22+
test frameworks (like pytest) have already configured logging handlers.
23+
"""
24+
25+
def __enter__(self):
26+
"""Save and clear logging handlers before entering the context."""
27+
self._old_handlers = logging.root.handlers[:]
28+
logging.root.handlers.clear()
29+
# Call parent __enter__ if it exists (for cooperative inheritance)
30+
if hasattr(super(), "__enter__"):
31+
return super().__enter__()
32+
return self
33+
34+
def __exit__(self, exc_type, exc_val, exc_tb):
35+
"""Restore logging handlers after exiting the context."""
36+
if hasattr(self, "_old_handlers"):
37+
logging.root.handlers[:] = self._old_handlers
38+
# Call parent __exit__ if it exists (for cooperative inheritance)
39+
if hasattr(super(), "__exit__"):
40+
return super().__exit__(exc_type, exc_val, exc_tb)
41+
return None
42+
43+
44+
class ArgvContext(LoggingIsolationMixin):
1545
"""
1646
A simple context manager allowing to temporarily override ``sys.argv``.
1747
@@ -25,13 +55,16 @@ def __init__(self, *new_args):
2555
self.args = type(self._old)(new_args)
2656

2757
def __enter__(self):
58+
super().__enter__()
2859
sys.argv = self.args
60+
return self
2961

3062
def __exit__(self, exc_type, exc_val, exc_tb):
3163
sys.argv = self._old
64+
super().__exit__(exc_type, exc_val, exc_tb)
3265

3366

34-
class EnvironContext(patch.dict):
67+
class EnvironContext(LoggingIsolationMixin, patch.dict):
3568
"""
3669
A simple context manager allowing to temporarily set environment values.
3770
@@ -57,8 +90,13 @@ def __enter__(self):
5790
with contextlib.suppress(KeyError):
5891
self.in_dict.pop(key)
5992

93+
return self
94+
95+
def __exit__(self, exc_type, exc_val, exc_tb):
96+
return super().__exit__(exc_type, exc_val, exc_tb)
97+
6098

61-
class RandomDirectoryContext(TemporaryDirectory):
99+
class RandomDirectoryContext(LoggingIsolationMixin, TemporaryDirectory):
62100
"""
63101
Change the execution directory to a random location, temporarily.
64102
@@ -69,10 +107,14 @@ class RandomDirectoryContext(TemporaryDirectory):
69107
https://docs.python.org/3/library/tempfile.html#tempfile.TemporaryDirectory
70108
"""
71109

110+
def __init__(self, *args, **kwargs):
111+
super().__init__(*args, **kwargs)
112+
72113
def __enter__(self):
73114
"""Create a temporary directory and ``cd`` into it."""
74-
self.__prev_dir = os.getcwd()
75115
super().__enter__()
116+
117+
self.__prev_dir = os.getcwd()
76118
os.chdir(self.name)
77119
return os.getcwd()
78120

docs/tutorial.rst

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,13 @@ this approach with mocking a target function, e.g.
9292
9393
assert mock_command.called
9494
95+
``ArgvContext`` also provides automatic logging isolation. When test
96+
frameworks like pytest set up logging handlers, they can interfere with
97+
code under test that calls ``logging.basicConfig()``. ``ArgvContext``
98+
automatically saves and clears logging handlers when entering the context,
99+
then restores them when exiting, allowing your CLI code to configure
100+
logging as expected.
101+
95102
See more |example code (argparse-cli)|_.
96103

97104
``EnvironContext``
@@ -111,6 +118,10 @@ See more |example code (argparse-cli)|_.
111118
foobar.command.baz()
112119
pytest.fail("CLI doesn't abort with missing SECRET")
113120
121+
``EnvironContext`` also provides automatic logging isolation, just like
122+
``ArgvContext``. This allows code under test to configure logging as expected
123+
even when test frameworks have already set up handlers.
124+
114125
See more |example code (click-command)|_.
115126

116127
``RandomDirectoryContext``
@@ -127,6 +138,10 @@ system:
127138
with ArgvContext('foobar', 'load'), RandomDirectoryContext():
128139
foobar.cli.main()
129140
141+
``RandomDirectoryContext`` also provides automatic logging isolation, ensuring
142+
that code under test can call ``logging.basicConfig()`` successfully regardless
143+
of test framework logging configuration.
144+
130145

131146
.. |example code (argparse-cli)| replace:: example code
132147
.. |example code (click-cli)| replace:: example code

tests/test_logisolation.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""Tests for our tests helpers. 8-}."""
2+
3+
import logging
4+
import tempfile
5+
from pathlib import Path
6+
7+
from cli_test_helpers import ArgvContext, EnvironContext, RandomDirectoryContext
8+
9+
10+
def test_argv_context_logging_isolation():
11+
"""
12+
Does ArgvContext isolate logging configuration?
13+
14+
Verifies that ArgvContext temporarily clears logging handlers, allowing
15+
code under test to call logging.basicConfig() successfully even when
16+
test frameworks have already configured logging.
17+
"""
18+
original_handlers = logging.root.handlers[:]
19+
mock_handler = logging.NullHandler()
20+
logging.root.handlers.append(mock_handler)
21+
22+
try:
23+
assert mock_handler in logging.root.handlers, "Test setup failed"
24+
25+
with tempfile.TemporaryDirectory() as tmpdir:
26+
logfile = Path(tmpdir) / "test.log"
27+
28+
with ArgvContext("test_script.py", str(logfile)):
29+
handlers_before = logging.root.handlers[:]
30+
assert not handlers_before, "Handlers not cleared"
31+
32+
logging.basicConfig(filename=str(logfile), level=logging.INFO)
33+
34+
logger = logging.getLogger(__name__)
35+
logger.info("Test message")
36+
37+
assert mock_handler in logging.root.handlers, "Handlers not restored"
38+
39+
assert logfile.exists(), "Log file should have been created"
40+
log_content = logfile.read_text()
41+
assert "Test message" in log_content, "Unexpected log file content"
42+
43+
finally:
44+
logging.root.handlers = original_handlers
45+
46+
47+
def test_environ_context_logging_isolation():
48+
"""
49+
Does EnvironContext isolate logging configuration?
50+
51+
Verifies that EnvironContext temporarily clears logging handlers, allowing
52+
code under test to call logging.basicConfig() successfully even when test
53+
frameworks have already configured logging.
54+
"""
55+
original_handlers = logging.root.handlers[:]
56+
mock_handler = logging.NullHandler()
57+
logging.root.handlers.append(mock_handler)
58+
59+
try:
60+
assert mock_handler in logging.root.handlers, "Test setup failed"
61+
62+
with tempfile.TemporaryDirectory() as tmpdir:
63+
logfile = Path(tmpdir) / "environ_test.log"
64+
65+
with EnvironContext(TEST_VAR="test_value"):
66+
handlers_before = logging.root.handlers[:]
67+
assert not handlers_before, "Handlers not cleared"
68+
69+
logging.basicConfig(filename=str(logfile), level=logging.INFO)
70+
71+
logger = logging.getLogger(__name__)
72+
logger.info("Test message from EnvironContext")
73+
74+
assert mock_handler in logging.root.handlers, "Handlers not restored"
75+
76+
assert logfile.exists(), "Log file should have been created"
77+
log_content = logfile.read_text()
78+
assert "Test message" in log_content, "Unexpected log file content"
79+
80+
finally:
81+
logging.root.handlers = original_handlers
82+
83+
84+
def test_random_directory_context_logging_isolation():
85+
"""
86+
Does RandomDirectoryContext isolate logging configuration?
87+
88+
Verifies that RandomDirectoryContext temporarily clears logging handlers,
89+
allowing code under test to call logging.basicConfig() successfully even
90+
when test frameworks have already configured logging.
91+
"""
92+
original_handlers = logging.root.handlers[:]
93+
mock_handler = logging.NullHandler()
94+
logging.root.handlers.append(mock_handler)
95+
96+
try:
97+
assert mock_handler in logging.root.handlers, "Test setup failed"
98+
99+
with RandomDirectoryContext():
100+
logfile = Path("random_dir_test.log")
101+
102+
handlers_before = logging.root.handlers[:]
103+
assert not handlers_before, "Handlers not cleared"
104+
105+
logging.basicConfig(filename=str(logfile), level=logging.INFO)
106+
107+
logger = logging.getLogger(__name__)
108+
logger.info("Test message from RandomDirectoryContext")
109+
110+
assert logfile.exists(), "Log file should have been created"
111+
log_content = logfile.read_text()
112+
assert "Test message" in log_content, "Unexpected log file content"
113+
114+
assert mock_handler in logging.root.handlers, "Handlers not restored"
115+
116+
finally:
117+
logging.root.handlers = original_handlers

0 commit comments

Comments
 (0)