From 4722f5e9586be5e7c3cd7c858f8385321d76bfe5 Mon Sep 17 00:00:00 2001 From: shivamtiwari3 <33183708+shivamtiwari3@users.noreply.github.com> Date: Tue, 10 Mar 2026 01:17:20 +0530 Subject: [PATCH] Use Python's logging framework for INFO messages in core.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: https://github.com/google/python-fire/issues/353 --- fire/core.py | 52 +++++++++++++++++++++++++++++++++--- fire/core_test.py | 68 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 4 deletions(-) diff --git a/fire/core.py b/fire/core.py index 8e23e76b..134a9525 100644 --- a/fire/core.py +++ b/fire/core.py @@ -52,6 +52,7 @@ def main(argv): import asyncio import inspect import json +import logging import os import re import shlex @@ -70,6 +71,51 @@ def main(argv): from fire.console import console_io +class _LazyStderrStreamHandler(logging.StreamHandler): + """A StreamHandler that resolves sys.stderr dynamically at emit time. + + The standard StreamHandler captures a reference to the stream object at + construction time. This subclass overrides that behaviour so that it always + writes to whatever sys.stderr currently refers to. This is important for + test code that replaces sys.stderr with an in-memory buffer via + unittest.mock.patch.object(sys, 'stderr', ...). + """ + + @property + def stream(self): + return sys.stderr + + @stream.setter + def stream(self, value): + pass # Always use sys.stderr; ignore any value stored at construction time. + + +# Fire's internal logger. By default it writes INFO-level messages to stderr, +# preserving the behaviour that existed before this logging integration was +# added. Callers that want to suppress or redirect these messages can +# configure the 'fire' or 'fire.core' logger, e.g.: +# +# import logging +# logging.getLogger('fire').setLevel(logging.WARNING) # suppress INFO msgs +# +_logger = logging.getLogger(__name__) +if not _logger.handlers: + _handler = _LazyStderrStreamHandler() + _handler.setFormatter(logging.Formatter('%(levelname)s: %(message)s')) + _logger.addHandler(_handler) + _logger.propagate = False + +# Set INFO on the parent 'fire' logger (not on fire.core itself) so that +# the effective level for fire.core is inherited from the hierarchy. This +# lets users suppress all fire messages by adjusting the 'fire' logger: +# logging.getLogger('fire').setLevel(logging.WARNING) +# or target just this module with: +# logging.getLogger('fire.core').setLevel(logging.WARNING) +_fire_parent_logger = logging.getLogger('fire') +if _fire_parent_logger.level == logging.NOTSET: + _fire_parent_logger.setLevel(logging.INFO) + + def Fire(component=None, command=None, name=None, serialize=None): """This function, Fire, is the main entrypoint for Python Fire. @@ -231,8 +277,7 @@ def _IsHelpShortcut(component_trace, remaining_args): if show_help: component_trace.show_help = True command = f'{component_trace.GetCommand()} -- --help' - print(f'INFO: Showing help with the command {shlex.quote(command)}.\n', - file=sys.stderr) + _logger.info('Showing help with the command %s.\n', shlex.quote(command)) return show_help @@ -287,8 +332,7 @@ def _DisplayError(component_trace): if show_help: command = f'{component_trace.GetCommand()} -- --help' - print(f'INFO: Showing help with the command {shlex.quote(command)}.\n', - file=sys.stderr) + _logger.info('Showing help with the command %s.\n', shlex.quote(command)) help_text = helptext.HelpText(result, trace=component_trace, verbose=component_trace.verbose) output.append(help_text) diff --git a/fire/core_test.py b/fire/core_test.py index f48d6e2d..55747069 100644 --- a/fire/core_test.py +++ b/fire/core_test.py @@ -14,6 +14,9 @@ """Tests for the core module.""" +import io +import logging +import sys from unittest import mock from fire import core @@ -224,5 +227,70 @@ def testLruCacheDecorator(self): command=['foo']), 'foo') +class LoggingTest(testutils.BaseTestCase): + """Tests that INFO messages use the Python logging framework (issue #353).""" + + def testInfoMessageAppearsOnStderrByDefault(self): + """The 'Showing help' INFO line must appear in stderr without any logging config.""" + with self.assertRaisesFireExit(0, r'INFO:.*Showing help'): + core.Fire(tc.InstanceVars, command=['--help']) + + def testInfoMessageUsesFireLogger(self): + """core._logger must be a Logger named 'fire.core'.""" + self.assertIsInstance(core._logger, logging.Logger) # pylint: disable=protected-access + self.assertEqual(core._logger.name, 'fire.core') # pylint: disable=protected-access + + def testInfoCanBeSuppressedViaLogging(self): + """Users can suppress the INFO line by raising the fire.core logger level.""" + fire_logger = logging.getLogger('fire.core') + original_level = fire_logger.level + try: + fire_logger.setLevel(logging.WARNING) + stderr_fp = io.StringIO() + with mock.patch.object(sys, 'stderr', stderr_fp): + with self.assertRaises(core.FireExit): + core.Fire(tc.InstanceVars, command=['--help']) + self.assertNotIn('INFO:', stderr_fp.getvalue()) + finally: + fire_logger.setLevel(original_level) + + def testInfoCanBeSuppressedViaParentLogger(self): + """Users can suppress the INFO line by raising the parent 'fire' logger level.""" + fire_logger = logging.getLogger('fire') + original_level = fire_logger.level + try: + fire_logger.setLevel(logging.WARNING) + stderr_fp = io.StringIO() + with mock.patch.object(sys, 'stderr', stderr_fp): + with self.assertRaises(core.FireExit): + core.Fire(tc.InstanceVars, command=['--help']) + self.assertNotIn('INFO:', stderr_fp.getvalue()) + finally: + fire_logger.setLevel(original_level) + + def testInfoCanBeRedirectedViaCustomHandler(self): + """Users can capture fire's log output by adding their own handler.""" + fire_logger = logging.getLogger('fire.core') + custom_stream = io.StringIO() + custom_handler = logging.StreamHandler(custom_stream) + custom_handler.setFormatter(logging.Formatter('%(levelname)s: %(message)s')) + + # Remove the default handler, add a custom one to redirect to custom_stream. + original_handlers = fire_logger.handlers[:] + original_propagate = fire_logger.propagate + fire_logger.handlers = [custom_handler] + try: + stderr_fp = io.StringIO() + with mock.patch.object(sys, 'stderr', stderr_fp): + with self.assertRaises(core.FireExit): + core.Fire(tc.InstanceVars, command=['--help']) + # INFO message should appear in our custom stream, not stderr. + self.assertIn('INFO:', custom_stream.getvalue()) + self.assertNotIn('INFO:', stderr_fp.getvalue()) + finally: + fire_logger.handlers = original_handlers + fire_logger.propagate = original_propagate + + if __name__ == '__main__': testutils.main()