diff --git a/changelog/9644.improvement.rst b/changelog/9644.improvement.rst new file mode 100644 index 0000000000..6e29fd57a7 --- /dev/null +++ b/changelog/9644.improvement.rst @@ -0,0 +1,4 @@ +More information about the location of resources that led Python to raise :class:`ResourceWarning` can now +be obtained by enabling :mod:`tracemalloc`. + +See :ref:`resource-warnings` for more information. diff --git a/doc/en/how-to/capture-warnings.rst b/doc/en/how-to/capture-warnings.rst index 8c911424af..4a27767e52 100644 --- a/doc/en/how-to/capture-warnings.rst +++ b/doc/en/how-to/capture-warnings.rst @@ -441,3 +441,18 @@ Please read our :ref:`backwards-compatibility` to learn how we proceed about dep features. The full list of warnings is listed in :ref:`the reference documentation `. + + +.. _`resource-warnings`: + +Resource Warnings +----------------- + +Additional information of the source of a :class:`ResourceWarning` can be obtained when captured by pytest if +:mod:`tracemalloc` module is enabled. + +One convenient way to enable :mod:`tracemalloc` when running tests is to set the :envvar:`PYTHONTRACEMALLOC` to a large +enough number of frames (say ``20``, but that number is application dependent). + +For more information, consult the `Python Development Mode `__ +section in the Python documentation. diff --git a/src/_pytest/warnings.py b/src/_pytest/warnings.py index 154283632c..8fdacfcc1a 100644 --- a/src/_pytest/warnings.py +++ b/src/_pytest/warnings.py @@ -81,6 +81,25 @@ def warning_record_to_str(warning_message: warnings.WarningMessage) -> str: warning_message.lineno, warning_message.line, ) + if warning_message.source is not None: + try: + import tracemalloc + except ImportError: + pass + else: + tb = tracemalloc.get_object_traceback(warning_message.source) + if tb is not None: + formatted_tb = "\n".join(tb.format()) + # Use a leading new line to better separate the (large) output + # from the traceback to the previous warning text. + msg += ( + f"\nObject allocated at (most recent call first):\n{formatted_tb}" + ) + else: + # No need for a leading new line. + url = "https://docs.pytest.org/en/stable/how-to/capture-warnings.html#resource-warnings" + msg += "Enable tracemalloc to get traceback where the object was allocated.\n" + msg += f"See {url} for more info." return msg diff --git a/testing/test_warnings.py b/testing/test_warnings.py index 734e1ebb5c..4d36349918 100644 --- a/testing/test_warnings.py +++ b/testing/test_warnings.py @@ -1,4 +1,5 @@ import os +import sys import warnings from typing import List from typing import Optional @@ -774,3 +775,53 @@ def test_it(): "*Unknown pytest.mark.unknown*", ] ) + + +def test_resource_warning(pytester: Pytester, monkeypatch: pytest.MonkeyPatch) -> None: + # Some platforms (notably PyPy) don't have tracemalloc. + # We choose to explicitly not skip this in case tracemalloc is not + # available, using `importorskip("tracemalloc")` for example, + # because we want to ensure the same code path does not break in those platforms. + try: + import tracemalloc # noqa + + has_tracemalloc = True + except ImportError: + has_tracemalloc = False + + pytester.makepyfile( + """ + def open_file(p): + f = p.open("r") + assert p.read_text() == "hello" + + def test_resource_warning(tmp_path): + p = tmp_path.joinpath("foo.txt") + p.write_text("hello") + open_file(p) + """ + ) + result = pytester.run(sys.executable, "-Xdev", "-m", "pytest") + expected_extra = ( + [ + "*ResourceWarning* unclosed file*", + "*Enable tracemalloc to get traceback where the object was allocated*", + "*See https* for more info.", + ] + if has_tracemalloc + else [] + ) + result.stdout.fnmatch_lines([*expected_extra, "*1 passed*"]) + + monkeypatch.setenv("PYTHONTRACEMALLOC", "20") + + result = pytester.run(sys.executable, "-Xdev", "-m", "pytest") + expected_extra = ( + [ + "*ResourceWarning* unclosed file*", + "*Object allocated at (most recent call first)*", + ] + if has_tracemalloc + else [] + ) + result.stdout.fnmatch_lines([*expected_extra, "*1 passed*"])