Skip to content

Commit

Permalink
Add --log-file-mode option to the logging plugin, enabling appending …
Browse files Browse the repository at this point in the history
…to log-files (pytest-dev#11979)

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

Co-authored-by: Bruno Oliveira <bruno@soliv.dev>
  • Loading branch information
2 people authored and flying-sheep committed Apr 9, 2024
1 parent 985a54a commit 4c367fe
Show file tree
Hide file tree
Showing 5 changed files with 188 additions and 4 deletions.
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ Babak Keyvani
Barney Gale
Ben Brown
Ben Gartner
Ben Leith
Ben Webb
Benjamin Peterson
Benjamin Schubert
Expand Down
3 changes: 3 additions & 0 deletions changelog/11978.improvement.rst
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 4 additions & 2 deletions doc/en/how-to/logging.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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:

Expand Down
14 changes: 12 additions & 2 deletions src/_pytest/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,13 @@ def add_option_ini(option, dest, default=None, type=None, **kwargs):
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",
Expand Down Expand Up @@ -669,7 +676,10 @@ def __init__(self, config: Config) -> None:
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"
Expand Down Expand Up @@ -746,7 +756,7 @@ def set_log_path(self, fname: str) -> None:
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()
Expand Down
168 changes: 168 additions & 0 deletions testing/logging/test_reporting.py
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,73 @@ def test_log_file(request):
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(
Expand Down Expand Up @@ -741,6 +808,47 @@ def test_log_file(request):
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"))

Expand Down Expand Up @@ -1060,6 +1168,66 @@ def test_second():
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."""
Expand Down

0 comments on commit 4c367fe

Please sign in to comment.