Skip to content

Commit 3797144

Browse files
bittnerCopilot
andcommitted
Add logging isolation for Pytest compatibility
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
1 parent cf09c2d commit 3797144

File tree

4 files changed

+195
-7
lines changed

4 files changed

+195
-7
lines changed

cli_test_helpers/decorators.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@
88
from tempfile import TemporaryDirectory
99
from unittest.mock import patch
1010

11+
from .mixins import LoggingIsolationMixin
12+
1113
__all__ = []
1214

1315

14-
class ArgvContext:
16+
class ArgvContext(LoggingIsolationMixin):
1517
"""
1618
A simple context manager allowing to temporarily override ``sys.argv``.
1719
@@ -25,13 +27,16 @@ def __init__(self, *new_args):
2527
self.args = type(self._old)(new_args)
2628

2729
def __enter__(self):
30+
super().__enter__()
2831
sys.argv = self.args
32+
return self
2933

3034
def __exit__(self, exc_type, exc_val, exc_tb):
3135
sys.argv = self._old
36+
super().__exit__(exc_type, exc_val, exc_tb)
3237

3338

34-
class EnvironContext(patch.dict):
39+
class EnvironContext(LoggingIsolationMixin, patch.dict):
3540
"""
3641
A simple context manager allowing to temporarily set environment values.
3742
@@ -57,8 +62,13 @@ def __enter__(self):
5762
with contextlib.suppress(KeyError):
5863
self.in_dict.pop(key)
5964

65+
return self
66+
67+
def __exit__(self, exc_type, exc_val, exc_tb):
68+
return super().__exit__(exc_type, exc_val, exc_tb)
69+
6070

61-
class RandomDirectoryContext(TemporaryDirectory):
71+
class RandomDirectoryContext(LoggingIsolationMixin, TemporaryDirectory):
6272
"""
6373
Change the execution directory to a random location, temporarily.
6474
@@ -69,10 +79,14 @@ class RandomDirectoryContext(TemporaryDirectory):
6979
https://docs.python.org/3/library/tempfile.html#tempfile.TemporaryDirectory
7080
"""
7181

82+
def __init__(self, *args, **kwargs):
83+
super().__init__(*args, **kwargs)
84+
7285
def __enter__(self):
7386
"""Create a temporary directory and ``cd`` into it."""
74-
self.__prev_dir = os.getcwd()
7587
super().__enter__()
88+
89+
self.__prev_dir = os.getcwd()
7690
os.chdir(self.name)
7791
return os.getcwd()
7892

cli_test_helpers/mixins.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""
2+
Mixins for all context managers (decorators). Not for direct, public use.
3+
"""
4+
5+
import logging
6+
7+
8+
class LoggingIsolationMixin:
9+
"""
10+
Mixin that provides automatic logging configuration isolation.
11+
12+
Isolates logging configuration by temporarily clearing logging handlers
13+
when entering the context and restoring them when exiting. This allows
14+
code under test to call ``logging.basicConfig()`` successfully even when
15+
test frameworks (like pytest) have already configured logging handlers.
16+
17+
All context managers use this mixin.
18+
"""
19+
20+
def __enter__(self):
21+
"""Save and clear logging handlers before entering the context."""
22+
self._old_handlers = logging.root.handlers[:]
23+
logging.root.handlers.clear()
24+
if hasattr(super(), "__enter__"): # cooperative inheritance (MRO)
25+
return super().__enter__()
26+
return self
27+
28+
def __exit__(self, exc_type, exc_val, exc_tb):
29+
"""
30+
Restore logging handlers after exiting the context.
31+
32+
Closes and removes any handlers that were added during the context.
33+
"""
34+
handlers_to_close = [
35+
handler
36+
for handler in logging.root.handlers
37+
if handler not in self._old_handlers
38+
]
39+
40+
for handler in handlers_to_close:
41+
handler.close()
42+
logging.root.handlers.remove(handler)
43+
44+
logging.root.handlers[:] = self._old_handlers
45+
46+
if hasattr(super(), "__exit__"): # cooperative inheritance (MRO)
47+
return super().__exit__(exc_type, exc_val, exc_tb)
48+
return None

docs/api.rst

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,20 @@ by the CLI test helpers package.
99
Context Managers
1010
----------------
1111

12-
.. autoclass:: ArgvContext
12+
.. autoclass:: cli_test_helpers.ArgvContext
1313

14-
.. autoclass:: EnvironContext
14+
.. autoclass:: cli_test_helpers.EnvironContext
1515

16-
.. autoclass:: RandomDirectoryContext
16+
.. autoclass:: cli_test_helpers.RandomDirectoryContext
17+
18+
Mixins
19+
------
20+
21+
.. note::
22+
23+
Used internally by all context managers, not meant for direct use.
24+
25+
.. autoclass:: cli_test_helpers.mixins.LoggingIsolationMixin
1726

1827
Utilities
1928
---------

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)