Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 4 commits into from
Feb 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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 @@
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 @@
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 @@
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]

Check warning on line 759 in src/_pytest/logging.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/logging.py#L759

Added line #L759 was not covered by tests
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