Skip to content

Commit 4722f5e

Browse files
committed
Use Python's logging framework for INFO messages in core.py
Previously, the two 'INFO: Showing help …' diagnostics were emitted via bare print() calls to sys.stderr. This made it impossible for users to suppress or redirect the messages using the standard logging machinery. This commit replaces those calls with a proper Python logging setup: * Adds _LazyStderrStreamHandler – a StreamHandler subclass that resolves sys.stderr at emit time (via a property) rather than at construction time. This is required so that unit-test code that patches sys.stderr with an in-memory buffer continues to work correctly. * Adds _logger = logging.getLogger('fire.core') and installs the lazy handler on it with level NOTSET, so the effective level is inherited from the parent 'fire' logger. * Sets the parent 'fire' logger to INFO (only if it has not already been configured) so that the default user experience is identical to before: the INFO line still appears on stderr without any logging configuration. * Users can now suppress the message with a single line: import logging; logging.getLogger('fire').setLevel(logging.WARNING) or redirect it to an arbitrary destination by adding their own handler to logging.getLogger('fire') or logging.getLogger('fire.core'). Adds four new tests in LoggingTest covering: - Default stderr output is preserved. - _logger is a logging.Logger named 'fire.core'. - INFO can be suppressed via the fire.core logger level. - INFO can be suppressed via the parent fire logger level. - INFO can be redirected through a custom handler. All 265 existing tests continue to pass. Fixes: #353
1 parent 716bbc2 commit 4722f5e

File tree

2 files changed

+116
-4
lines changed

2 files changed

+116
-4
lines changed

fire/core.py

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ def main(argv):
5252
import asyncio
5353
import inspect
5454
import json
55+
import logging
5556
import os
5657
import re
5758
import shlex
@@ -70,6 +71,51 @@ def main(argv):
7071
from fire.console import console_io
7172

7273

74+
class _LazyStderrStreamHandler(logging.StreamHandler):
75+
"""A StreamHandler that resolves sys.stderr dynamically at emit time.
76+
77+
The standard StreamHandler captures a reference to the stream object at
78+
construction time. This subclass overrides that behaviour so that it always
79+
writes to whatever sys.stderr currently refers to. This is important for
80+
test code that replaces sys.stderr with an in-memory buffer via
81+
unittest.mock.patch.object(sys, 'stderr', ...).
82+
"""
83+
84+
@property
85+
def stream(self):
86+
return sys.stderr
87+
88+
@stream.setter
89+
def stream(self, value):
90+
pass # Always use sys.stderr; ignore any value stored at construction time.
91+
92+
93+
# Fire's internal logger. By default it writes INFO-level messages to stderr,
94+
# preserving the behaviour that existed before this logging integration was
95+
# added. Callers that want to suppress or redirect these messages can
96+
# configure the 'fire' or 'fire.core' logger, e.g.:
97+
#
98+
# import logging
99+
# logging.getLogger('fire').setLevel(logging.WARNING) # suppress INFO msgs
100+
#
101+
_logger = logging.getLogger(__name__)
102+
if not _logger.handlers:
103+
_handler = _LazyStderrStreamHandler()
104+
_handler.setFormatter(logging.Formatter('%(levelname)s: %(message)s'))
105+
_logger.addHandler(_handler)
106+
_logger.propagate = False
107+
108+
# Set INFO on the parent 'fire' logger (not on fire.core itself) so that
109+
# the effective level for fire.core is inherited from the hierarchy. This
110+
# lets users suppress all fire messages by adjusting the 'fire' logger:
111+
# logging.getLogger('fire').setLevel(logging.WARNING)
112+
# or target just this module with:
113+
# logging.getLogger('fire.core').setLevel(logging.WARNING)
114+
_fire_parent_logger = logging.getLogger('fire')
115+
if _fire_parent_logger.level == logging.NOTSET:
116+
_fire_parent_logger.setLevel(logging.INFO)
117+
118+
73119
def Fire(component=None, command=None, name=None, serialize=None):
74120
"""This function, Fire, is the main entrypoint for Python Fire.
75121
@@ -231,8 +277,7 @@ def _IsHelpShortcut(component_trace, remaining_args):
231277
if show_help:
232278
component_trace.show_help = True
233279
command = f'{component_trace.GetCommand()} -- --help'
234-
print(f'INFO: Showing help with the command {shlex.quote(command)}.\n',
235-
file=sys.stderr)
280+
_logger.info('Showing help with the command %s.\n', shlex.quote(command))
236281
return show_help
237282

238283

@@ -287,8 +332,7 @@ def _DisplayError(component_trace):
287332

288333
if show_help:
289334
command = f'{component_trace.GetCommand()} -- --help'
290-
print(f'INFO: Showing help with the command {shlex.quote(command)}.\n',
291-
file=sys.stderr)
335+
_logger.info('Showing help with the command %s.\n', shlex.quote(command))
292336
help_text = helptext.HelpText(result, trace=component_trace,
293337
verbose=component_trace.verbose)
294338
output.append(help_text)

fire/core_test.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414

1515
"""Tests for the core module."""
1616

17+
import io
18+
import logging
19+
import sys
1720
from unittest import mock
1821

1922
from fire import core
@@ -224,5 +227,70 @@ def testLruCacheDecorator(self):
224227
command=['foo']), 'foo')
225228

226229

230+
class LoggingTest(testutils.BaseTestCase):
231+
"""Tests that INFO messages use the Python logging framework (issue #353)."""
232+
233+
def testInfoMessageAppearsOnStderrByDefault(self):
234+
"""The 'Showing help' INFO line must appear in stderr without any logging config."""
235+
with self.assertRaisesFireExit(0, r'INFO:.*Showing help'):
236+
core.Fire(tc.InstanceVars, command=['--help'])
237+
238+
def testInfoMessageUsesFireLogger(self):
239+
"""core._logger must be a Logger named 'fire.core'."""
240+
self.assertIsInstance(core._logger, logging.Logger) # pylint: disable=protected-access
241+
self.assertEqual(core._logger.name, 'fire.core') # pylint: disable=protected-access
242+
243+
def testInfoCanBeSuppressedViaLogging(self):
244+
"""Users can suppress the INFO line by raising the fire.core logger level."""
245+
fire_logger = logging.getLogger('fire.core')
246+
original_level = fire_logger.level
247+
try:
248+
fire_logger.setLevel(logging.WARNING)
249+
stderr_fp = io.StringIO()
250+
with mock.patch.object(sys, 'stderr', stderr_fp):
251+
with self.assertRaises(core.FireExit):
252+
core.Fire(tc.InstanceVars, command=['--help'])
253+
self.assertNotIn('INFO:', stderr_fp.getvalue())
254+
finally:
255+
fire_logger.setLevel(original_level)
256+
257+
def testInfoCanBeSuppressedViaParentLogger(self):
258+
"""Users can suppress the INFO line by raising the parent 'fire' logger level."""
259+
fire_logger = logging.getLogger('fire')
260+
original_level = fire_logger.level
261+
try:
262+
fire_logger.setLevel(logging.WARNING)
263+
stderr_fp = io.StringIO()
264+
with mock.patch.object(sys, 'stderr', stderr_fp):
265+
with self.assertRaises(core.FireExit):
266+
core.Fire(tc.InstanceVars, command=['--help'])
267+
self.assertNotIn('INFO:', stderr_fp.getvalue())
268+
finally:
269+
fire_logger.setLevel(original_level)
270+
271+
def testInfoCanBeRedirectedViaCustomHandler(self):
272+
"""Users can capture fire's log output by adding their own handler."""
273+
fire_logger = logging.getLogger('fire.core')
274+
custom_stream = io.StringIO()
275+
custom_handler = logging.StreamHandler(custom_stream)
276+
custom_handler.setFormatter(logging.Formatter('%(levelname)s: %(message)s'))
277+
278+
# Remove the default handler, add a custom one to redirect to custom_stream.
279+
original_handlers = fire_logger.handlers[:]
280+
original_propagate = fire_logger.propagate
281+
fire_logger.handlers = [custom_handler]
282+
try:
283+
stderr_fp = io.StringIO()
284+
with mock.patch.object(sys, 'stderr', stderr_fp):
285+
with self.assertRaises(core.FireExit):
286+
core.Fire(tc.InstanceVars, command=['--help'])
287+
# INFO message should appear in our custom stream, not stderr.
288+
self.assertIn('INFO:', custom_stream.getvalue())
289+
self.assertNotIn('INFO:', stderr_fp.getvalue())
290+
finally:
291+
fire_logger.handlers = original_handlers
292+
fire_logger.propagate = original_propagate
293+
294+
227295
if __name__ == '__main__':
228296
testutils.main()

0 commit comments

Comments
 (0)