Add --log-file-mode option to the logging plugin, enabling appending to log-files (#11979)

Previously, the mode was hard-coded to be "w" which truncates the file before logging.

Co-authored-by: Bruno Oliveira <bruno@soliv.dev>
This commit is contained in:
Ben Leith 2024-02-21 23:02:19 +11:00 committed by GitHub
parent 8a410d0ba6
commit c5c729e27a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 188 additions and 4 deletions

View File

@ -56,6 +56,7 @@ Babak Keyvani
Barney Gale
Ben Brown
Ben Gartner
Ben Leith
Ben Webb
Benjamin Peterson
Benjamin Schubert

View File

@ -0,0 +1,3 @@
Add ``--log-file-mode`` option to the logging plugin, enabling appending to log-files. This option accepts either ``"w"`` or ``"a"`` and defaults to ``"w"``.
Previously, the mode was hard-coded to be ``"w"`` which truncates the file before logging.

View File

@ -206,8 +206,9 @@ option names are:
* ``log_cli_date_format``
If you need to record the whole test suite logging calls to a file, you can pass
``--log-file=/path/to/log/file``. This log file is opened in write mode which
``--log-file=/path/to/log/file``. This log file is opened in write mode by default which
means that it will be overwritten at each run tests session.
If you'd like the file opened in append mode instead, then you can pass ``--log-file-mode=a``.
Note that relative paths for the log-file location, whether passed on the CLI or declared in a
config file, are always resolved relative to the current working directory.
@ -223,12 +224,13 @@ All of the log file options can also be set in the configuration INI file. The
option names are:
* ``log_file``
* ``log_file_mode``
* ``log_file_level``
* ``log_file_format``
* ``log_file_date_format``
You can call ``set_log_path()`` to customize the log_file path dynamically. This functionality
is considered **experimental**.
is considered **experimental**. Note that ``set_log_path()`` respects the ``log_file_mode`` option.
.. _log_colors:

View File

@ -298,6 +298,13 @@ def pytest_addoption(parser: Parser) -> None:
default=None,
help="Path to a file when logging will be written to",
)
add_option_ini(
"--log-file-mode",
dest="log_file_mode",
default="w",
choices=["w", "a"],
help="Log file open mode",
)
add_option_ini(
"--log-file-level",
dest="log_file_level",
@ -669,7 +676,10 @@ class LoggingPlugin:
if not os.path.isdir(directory):
os.makedirs(directory)
self.log_file_handler = _FileHandler(log_file, mode="w", encoding="UTF-8")
self.log_file_mode = get_option_ini(config, "log_file_mode") or "w"
self.log_file_handler = _FileHandler(
log_file, mode=self.log_file_mode, encoding="UTF-8"
)
log_file_format = get_option_ini(config, "log_file_format", "log_format")
log_file_date_format = get_option_ini(
config, "log_file_date_format", "log_date_format"
@ -746,7 +756,7 @@ class LoggingPlugin:
fpath.parent.mkdir(exist_ok=True, parents=True)
# https://github.com/python/mypy/issues/11193
stream: io.TextIOWrapper = fpath.open(mode="w", encoding="UTF-8") # type: ignore[assignment]
stream: io.TextIOWrapper = fpath.open(mode=self.log_file_mode, encoding="UTF-8") # type: ignore[assignment]
old_stream = self.log_file_handler.setStream(stream)
if old_stream:
old_stream.close()

View File

@ -661,6 +661,73 @@ def test_log_file_cli(pytester: Pytester) -> None:
assert "This log message won't be shown" not in contents
def test_log_file_mode_cli(pytester: Pytester) -> None:
# Default log file level
pytester.makepyfile(
"""
import pytest
import logging
def test_log_file(request):
plugin = request.config.pluginmanager.getplugin('logging-plugin')
assert plugin.log_file_handler.level == logging.WARNING
logging.getLogger('catchlog').info("This log message won't be shown")
logging.getLogger('catchlog').warning("This log message will be shown")
print('PASSED')
"""
)
log_file = str(pytester.path.joinpath("pytest.log"))
with open(log_file, mode="w", encoding="utf-8") as wfh:
wfh.write("A custom header\n")
result = pytester.runpytest(
"-s",
f"--log-file={log_file}",
"--log-file-mode=a",
"--log-file-level=WARNING",
)
# fnmatch_lines does an assertion internally
result.stdout.fnmatch_lines(["test_log_file_mode_cli.py PASSED"])
# make sure that we get a '0' exit code for the testsuite
assert result.ret == 0
assert os.path.isfile(log_file)
with open(log_file, encoding="utf-8") as rfh:
contents = rfh.read()
assert "A custom header" in contents
assert "This log message will be shown" in contents
assert "This log message won't be shown" not in contents
def test_log_file_mode_cli_invalid(pytester: Pytester) -> None:
# Default log file level
pytester.makepyfile(
"""
import pytest
import logging
def test_log_file(request):
plugin = request.config.pluginmanager.getplugin('logging-plugin')
assert plugin.log_file_handler.level == logging.WARNING
logging.getLogger('catchlog').info("This log message won't be shown")
logging.getLogger('catchlog').warning("This log message will be shown")
"""
)
log_file = str(pytester.path.joinpath("pytest.log"))
result = pytester.runpytest(
"-s",
f"--log-file={log_file}",
"--log-file-mode=b",
"--log-file-level=WARNING",
)
# make sure that we get a '4' exit code for the testsuite
assert result.ret == ExitCode.USAGE_ERROR
def test_log_file_cli_level(pytester: Pytester) -> None:
# Default log file level
pytester.makepyfile(
@ -741,6 +808,47 @@ def test_log_file_ini(pytester: Pytester) -> None:
assert "This log message won't be shown" not in contents
def test_log_file_mode_ini(pytester: Pytester) -> None:
log_file = str(pytester.path.joinpath("pytest.log"))
pytester.makeini(
f"""
[pytest]
log_file={log_file}
log_file_mode=a
log_file_level=WARNING
"""
)
pytester.makepyfile(
"""
import pytest
import logging
def test_log_file(request):
plugin = request.config.pluginmanager.getplugin('logging-plugin')
assert plugin.log_file_handler.level == logging.WARNING
logging.getLogger('catchlog').info("This log message won't be shown")
logging.getLogger('catchlog').warning("This log message will be shown")
print('PASSED')
"""
)
with open(log_file, mode="w", encoding="utf-8") as wfh:
wfh.write("A custom header\n")
result = pytester.runpytest("-s")
# fnmatch_lines does an assertion internally
result.stdout.fnmatch_lines(["test_log_file_mode_ini.py PASSED"])
assert result.ret == ExitCode.OK
assert os.path.isfile(log_file)
with open(log_file, encoding="utf-8") as rfh:
contents = rfh.read()
assert "A custom header" in contents
assert "This log message will be shown" in contents
assert "This log message won't be shown" not in contents
def test_log_file_ini_level(pytester: Pytester) -> None:
log_file = str(pytester.path.joinpath("pytest.log"))
@ -1060,6 +1168,66 @@ def test_log_set_path(pytester: Pytester) -> None:
assert "message from test 2" in content
def test_log_set_path_with_log_file_mode(pytester: Pytester) -> None:
report_dir_base = str(pytester.path)
pytester.makeini(
"""
[pytest]
log_file_level = DEBUG
log_cli=true
log_file_mode=a
"""
)
pytester.makeconftest(
f"""
import os
import pytest
@pytest.hookimpl(wrapper=True, tryfirst=True)
def pytest_runtest_setup(item):
config = item.config
logging_plugin = config.pluginmanager.get_plugin("logging-plugin")
report_file = os.path.join({report_dir_base!r}, item._request.node.name)
logging_plugin.set_log_path(report_file)
return (yield)
"""
)
pytester.makepyfile(
"""
import logging
logger = logging.getLogger("testcase-logger")
def test_first():
logger.info("message from test 1")
assert True
def test_second():
logger.debug("message from test 2")
assert True
"""
)
test_first_log_file = os.path.join(report_dir_base, "test_first")
test_second_log_file = os.path.join(report_dir_base, "test_second")
with open(test_first_log_file, mode="w", encoding="utf-8") as wfh:
wfh.write("A custom header for test 1\n")
with open(test_second_log_file, mode="w", encoding="utf-8") as wfh:
wfh.write("A custom header for test 2\n")
result = pytester.runpytest()
assert result.ret == ExitCode.OK
with open(test_first_log_file, encoding="utf-8") as rfh:
content = rfh.read()
assert "A custom header for test 1" in content
assert "message from test 1" in content
with open(test_second_log_file, encoding="utf-8") as rfh:
content = rfh.read()
assert "A custom header for test 2" in content
assert "message from test 2" in content
def test_colored_captured_log(pytester: Pytester) -> None:
"""Test that the level names of captured log messages of a failing test
are colored."""