diff --git a/ChangeLog b/ChangeLog index 9b3a7a67a4..eb2e47525d 100644 --- a/ChangeLog +++ b/ChangeLog @@ -15,6 +15,11 @@ Release date: TBA Closes #1512 +* Capture and log messages emitted by C extensions when importing them. + This prevents contaminating programmatic output, e.g. pylint's JSON reporter. + + Closes PyCQA/pylint#3518 + * Remove dependency on ``pkg_resources`` from ``setuptools``. Closes #1103 diff --git a/astroid/modutils.py b/astroid/modutils.py index 6ae3fcad5d..1cd950956c 100644 --- a/astroid/modutils.py +++ b/astroid/modutils.py @@ -19,17 +19,23 @@ import importlib import importlib.machinery import importlib.util +import io import itertools +import logging import os import sys import sysconfig import types +from contextlib import redirect_stderr, redirect_stdout from functools import lru_cache from pathlib import Path from astroid.const import IS_JYTHON, IS_PYPY from astroid.interpreter._import import spec, util +logger = logging.getLogger(__name__) + + if sys.platform.startswith("win"): PY_SOURCE_EXTS = ("py", "pyw") PY_COMPILED_EXTS = ("dll", "pyd") @@ -171,7 +177,25 @@ def load_module_from_name(dotted_name: str) -> types.ModuleType: except KeyError: pass - return importlib.import_module(dotted_name) + # Capture and log anything emitted during import to avoid + # contaminating JSON reports in pylint + with redirect_stderr(io.StringIO()) as stderr, redirect_stdout( + io.StringIO() + ) as stdout: + module = importlib.import_module(dotted_name) + + stderr_value = stderr.getvalue() + if stderr_value: + logger.error( + "Captured stderr while importing %s:\n%s", dotted_name, stderr_value + ) + stdout_value = stdout.getvalue() + if stdout_value: + logger.info( + "Captured stdout while importing %s:\n%s", dotted_name, stdout_value + ) + + return module def load_module_from_modpath(parts): diff --git a/tests/unittest_modutils.py b/tests/unittest_modutils.py index 3d15fb632c..96418b01ac 100644 --- a/tests/unittest_modutils.py +++ b/tests/unittest_modutils.py @@ -6,6 +6,7 @@ unit tests for module modutils (module manipulation utilities) """ import email +import logging import os import shutil import sys @@ -16,6 +17,8 @@ from xml import etree from xml.etree import ElementTree +from pytest import CaptureFixture, LogCaptureFixture + import astroid from astroid import modutils from astroid.interpreter._import import spec @@ -57,7 +60,7 @@ def test_find_egg_module(self) -> None: class LoadModuleFromNameTest(unittest.TestCase): - """load a python module from it's name""" + """load a python module from its name""" def test_known_values_load_module_from_name_1(self) -> None: self.assertEqual(modutils.load_module_from_name("sys"), sys) @@ -71,6 +74,38 @@ def test_raise_load_module_from_name_1(self) -> None: ) +def test_import_dotted_library( + capsys: CaptureFixture, + caplog: LogCaptureFixture, +) -> None: + caplog.set_level(logging.INFO) + original_module = sys.modules.pop("xml.etree.ElementTree") + expected_out = "INFO (TEST): Welcome to cElementTree!" + expected_err = "WARNING (TEST): Monkey-patched version of cElementTree" + + def function_with_stdout_and_stderr(expected_out, expected_err): + def mocked_function(*args, **kwargs): + print(f"{expected_out} args={args} kwargs={kwargs}") + print(expected_err, file=sys.stderr) + + return mocked_function + + try: + with unittest.mock.patch( + "importlib.import_module", + side_effect=function_with_stdout_and_stderr(expected_out, expected_err), + ): + modutils.load_module_from_name("xml.etree.ElementTree") + + out, err = capsys.readouterr() + assert expected_out in caplog.text + assert expected_err in caplog.text + assert not out + assert not err + finally: + sys.modules["xml.etree.ElementTree"] = original_module + + class GetModulePartTest(unittest.TestCase): """given a dotted name return the module part of the name"""