Skip to content

Commit 21c1694

Browse files
authored
Improve the logging tests and add support for resetting the logger (nv-morpheus#1716)
- Allows the logger to be "reset" which is necessary to avoid duplicate logging handlers if `configure_logging` is called multiple times - Updates all of the logging tests to parameterize the log level and better check if the handlers are actually being called. ## By Submitting this PR I confirm: - I am familiar with the [Contributing Guidelines](https://github.com/nv-morpheus/Morpheus/blob/main/docs/source/developer_guide/contributing.md). - When the PR is ready for review, new or existing tests cover these changes. - When the PR is ready for review, the documentation is up to date with these changes. Authors: - Michael Demoret (https://github.com/mdemoret-nv) - David Gardner (https://github.com/dagardner-nv) Approvers: - David Gardner (https://github.com/dagardner-nv) URL: nv-morpheus#1716
1 parent d9e6474 commit 21c1694

File tree

7 files changed

+197
-120
lines changed

7 files changed

+197
-120
lines changed

morpheus/utils/logger.py

+67-33
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import os
2222
import re
2323
import warnings
24+
import weakref
2425
from enum import Enum
2526

2627
import appdirs
@@ -116,49 +117,82 @@ def _configure_from_log_level(*extra_handlers: logging.Handler, log_level: int):
116117
# Get the root Morpheus logger
117118
morpheus_logger = logging.getLogger("morpheus")
118119

119-
# Set the level here
120-
set_log_level(log_level=log_level)
120+
# Prevent reconfiguration if called again
121+
if (not getattr(morpheus_logger, "_configured_by_morpheus", False)):
122+
setattr(morpheus_logger, "_configured_by_morpheus", True)
121123

122-
# Dont propagate upstream
123-
morpheus_logger.propagate = False
124-
morpheus_logging_queue = multiprocessing.Queue()
124+
# Set the level here
125+
set_log_level(log_level=log_level)
125126

126-
# This needs the be the only handler for morpheus logger
127-
morpheus_queue_handler = logging.handlers.QueueHandler(morpheus_logging_queue)
127+
# Dont propagate upstream
128+
morpheus_logger.propagate = False
129+
morpheus_logging_queue = multiprocessing.Queue()
128130

129-
# At this point, any morpheus logger will propagate upstream to the morpheus root and then be handled by the queue
130-
# handler
131-
morpheus_logger.addHandler(morpheus_queue_handler)
131+
# This needs the be the only handler for morpheus logger
132+
morpheus_queue_handler = logging.handlers.QueueHandler(morpheus_logging_queue)
132133

133-
log_file = os.path.join(appdirs.user_log_dir(appauthor="NVIDIA", appname="morpheus"), "morpheus.log")
134+
# At this point, any morpheus logger will propagate upstream to the morpheus root and then be handled by the
135+
# queue handler
136+
morpheus_logger.addHandler(morpheus_queue_handler)
134137

135-
# Ensure the log directory exists
136-
os.makedirs(os.path.dirname(log_file), exist_ok=True)
138+
log_file = os.path.join(appdirs.user_log_dir(appauthor="NVIDIA", appname="morpheus"), "morpheus.log")
137139

138-
# Now we build all of the handlers for the queue listener
139-
file_handler = logging.handlers.RotatingFileHandler(filename=log_file, backupCount=5, maxBytes=1000000)
140-
file_handler.setLevel(logging.DEBUG)
141-
file_handler.setFormatter(
142-
logging.Formatter('%(asctime)s - [%(levelname)s]: %(message)s {%(name)s, %(threadName)s}'))
140+
# Ensure the log directory exists
141+
os.makedirs(os.path.dirname(log_file), exist_ok=True)
143142

144-
# Tqdm stream handler (avoids messing with progress bars)
145-
console_handler = TqdmLoggingHandler()
143+
# Now we build all of the handlers for the queue listener
144+
file_handler = logging.handlers.RotatingFileHandler(filename=log_file, backupCount=5, maxBytes=1000000)
145+
file_handler.setLevel(logging.DEBUG)
146+
file_handler.setFormatter(
147+
logging.Formatter('%(asctime)s - [%(levelname)s]: %(message)s {%(name)s, %(threadName)s}'))
146148

147-
# Build and run the queue listener to actually process queued messages
148-
queue_listener = logging.handlers.QueueListener(morpheus_logging_queue,
149-
console_handler,
150-
file_handler,
151-
*extra_handlers,
152-
respect_handler_level=True)
153-
queue_listener.start()
154-
queue_listener._thread.name = "Logging Thread"
149+
# Tqdm stream handler (avoids messing with progress bars)
150+
console_handler = TqdmLoggingHandler()
155151

156-
# Register a function to kill the listener thread before shutting down. prevents error on intpreter close
157-
def stop_queue_listener():
158-
queue_listener.stop()
152+
# Build and run the queue listener to actually process queued messages
153+
queue_listener = logging.handlers.QueueListener(morpheus_logging_queue,
154+
console_handler,
155+
file_handler,
156+
*extra_handlers,
157+
respect_handler_level=True)
158+
queue_listener.start()
159+
queue_listener._thread.name = "Logging Thread"
159160

160-
import atexit
161-
atexit.register(stop_queue_listener)
161+
# Register a function to kill the listener thread when the queue_handler is removed.
162+
weakref.finalize(morpheus_queue_handler, queue_listener.stop)
163+
164+
# Register a handler before shutting down to remove all log handlers, this ensures that the weakref.finalize
165+
# handler we just defined is called at exit.
166+
import atexit
167+
atexit.register(reset_logging)
168+
else:
169+
raise RuntimeError("Logging has already been configured. Use `set_log_level` to change the log level or reset "
170+
"the logging system by calling `reset_logging`.")
171+
172+
173+
def reset_logging(logger_name: str = "morpheus"):
174+
"""
175+
Resets the Morpheus logging system. This will remove all handlers from the Morpheus logger and stop the queue
176+
listener. This is useful for testing where the logging system needs to be reconfigured multiple times or
177+
reconfigured with different settings.
178+
"""
179+
180+
morpheus_logger = logging.getLogger(logger_name)
181+
182+
for handler in morpheus_logger.handlers.copy():
183+
# Copied from `logging.shutdown`.
184+
try:
185+
handler.acquire()
186+
handler.flush()
187+
handler.close()
188+
except (OSError, ValueError):
189+
pass
190+
finally:
191+
handler.release()
192+
morpheus_logger.removeHandler(handler)
193+
194+
if hasattr(morpheus_logger, "_configured_by_morpheus"):
195+
delattr(morpheus_logger, "_configured_by_morpheus")
162196

163197

164198
def configure_logging(*extra_handlers: logging.Handler, log_level: int = None, log_config_file: str = None):

tests/benchmarks/conftest.py

+5
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@
2525
from test_bench_e2e_pipelines import E2E_TEST_CONFIGS
2626

2727

28+
@pytest.fixture(autouse=True)
29+
def reset_logging_fixture(reset_logging): # pylint: disable=unused-argument
30+
yield
31+
32+
2833
# pylint: disable=unused-argument
2934
def pytest_benchmark_update_json(config, benchmarks, output_json):
3035

tests/conftest.py

+8-6
Original file line numberDiff line numberDiff line change
@@ -192,16 +192,18 @@ def should_filter_test(item: pytest.Item):
192192
items[:] = [x for x in items if should_filter_test(x)]
193193

194194

195-
def clear_handlers(logger):
196-
handlers = logger.handlers.copy()
197-
for handler in handlers:
198-
logger.removeHandler(handler)
195+
@pytest.fixture(scope="function", name="reset_logging")
196+
def reset_logging_fixture():
197+
from morpheus.utils.logger import reset_logging
198+
reset_logging()
199+
yield
199200

200201

201202
@pytest.hookimpl(trylast=True)
202203
def pytest_runtest_teardown(item, nextitem):
203-
clear_handlers(logging.getLogger("morpheus"))
204-
clear_handlers(logging.getLogger())
204+
from morpheus.utils.logger import reset_logging
205+
reset_logging(logger_name="morpheus")
206+
reset_logging(logger_name=None) # Reset the root logger as well
205207

206208

207209
# This fixture will be used by all tests.

tests/test_cli.py

+9-18
Original file line numberDiff line numberDiff line change
@@ -144,28 +144,19 @@ def config_warning_fixture():
144144
@pytest.mark.use_python
145145
class TestCLI:
146146

147-
def test_help(self):
147+
@pytest.mark.parametrize('cmd',
148+
[[], ['tools'], ['run'], ['run', 'pipeline-ae'], ['run', 'pipeline-fil'],
149+
['run', 'pipeline-nlp'], ['run', 'pipeline-other']])
150+
def test_help(self, cmd: list[str]):
148151
runner = CliRunner()
149-
result = runner.invoke(commands.cli, ['--help'])
152+
result = runner.invoke(commands.cli, cmd + ['--help'])
150153
assert result.exit_code == 0, result.output
151154

152-
result = runner.invoke(commands.cli, ['tools', '--help'])
153-
assert result.exit_code == 0, result.output
154-
155-
result = runner.invoke(commands.cli, ['run', '--help'])
156-
assert result.exit_code == 0, result.output
157-
158-
result = runner.invoke(commands.cli, ['run', 'pipeline-ae', '--help'])
159-
assert result.exit_code == 0, result.output
160-
161-
def test_autocomplete(self, tmp_path):
155+
@pytest.mark.parametrize('cmd',
156+
[['tools', 'autocomplete', 'show'], ['tools', 'autocomplete', 'install', '--shell=bash']])
157+
def test_autocomplete(self, tmp_path, cmd: list[str]):
162158
runner = CliRunner()
163-
result = runner.invoke(commands.cli, ['tools', 'autocomplete', 'show'], env={'HOME': str(tmp_path)})
164-
assert result.exit_code == 0, result.output
165-
166-
# The actual results of this are specific to the implementation of click_completion
167-
result = runner.invoke(commands.cli, ['tools', 'autocomplete', 'install', '--shell=bash'],
168-
env={'HOME': str(tmp_path)})
159+
result = runner.invoke(commands.cli, cmd, env={'HOME': str(tmp_path)})
169160
assert result.exit_code == 0, result.output
170161

171162
@pytest.mark.usefixtures("restore_environ")

tests/test_dfp_kafka.py

+2-7
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
# See the License for the specific language governing permissions and
1515
# limitations under the License.
1616

17-
import logging
1817
import os
1918
import typing
2019
from io import StringIO
@@ -44,20 +43,16 @@
4443
from morpheus.stages.preprocess import train_ae_stage
4544
from morpheus.utils.compare_df import compare_df
4645
from morpheus.utils.file_utils import load_labels_file
47-
from morpheus.utils.logger import configure_logging
4846

4947
if (typing.TYPE_CHECKING):
5048
from kafka import KafkaConsumer
5149

52-
configure_logging(log_level=logging.DEBUG)
53-
# End-to-end test intended to imitate the dfp validation test
54-
5550

5651
@pytest.mark.kafka
5752
@pytest.mark.slow
5853
@pytest.mark.use_python
5954
@pytest.mark.reload_modules([commands, preprocess_ae_stage, train_ae_stage])
60-
@pytest.mark.usefixtures("reload_modules")
55+
@pytest.mark.usefixtures("reload_modules", "loglevel_debug")
6156
@mock.patch('morpheus.stages.preprocess.train_ae_stage.AutoEncoder')
6257
def test_dfp_roleg(mock_ae: mock.MagicMock,
6358
dataset_pandas: DatasetManager,
@@ -161,7 +156,7 @@ def test_dfp_roleg(mock_ae: mock.MagicMock,
161156
@pytest.mark.slow
162157
@pytest.mark.use_python
163158
@pytest.mark.reload_modules([preprocess_ae_stage, train_ae_stage])
164-
@pytest.mark.usefixtures("reload_modules")
159+
@pytest.mark.usefixtures("reload_modules", "loglevel_debug")
165160
@mock.patch('morpheus.stages.preprocess.train_ae_stage.AutoEncoder')
166161
def test_dfp_user123(mock_ae: mock.MagicMock,
167162
dataset_pandas: DatasetManager,

0 commit comments

Comments
 (0)