Skip to content

Commit c5c729e

Browse files
Leithal3nicoddemus
andauthored
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 <[email protected]>
1 parent 8a410d0 commit c5c729e

File tree

5 files changed

+188
-4
lines changed

5 files changed

+188
-4
lines changed

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ Babak Keyvani
5656
Barney Gale
5757
Ben Brown
5858
Ben Gartner
59+
Ben Leith
5960
Ben Webb
6061
Benjamin Peterson
6162
Benjamin Schubert

changelog/11978.improvement.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
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"``.
2+
3+
Previously, the mode was hard-coded to be ``"w"`` which truncates the file before logging.

doc/en/how-to/logging.rst

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,8 +206,9 @@ option names are:
206206
* ``log_cli_date_format``
207207

208208
If you need to record the whole test suite logging calls to a file, you can pass
209-
``--log-file=/path/to/log/file``. This log file is opened in write mode which
209+
``--log-file=/path/to/log/file``. This log file is opened in write mode by default which
210210
means that it will be overwritten at each run tests session.
211+
If you'd like the file opened in append mode instead, then you can pass ``--log-file-mode=a``.
211212
Note that relative paths for the log-file location, whether passed on the CLI or declared in a
212213
config file, are always resolved relative to the current working directory.
213214

@@ -223,12 +224,13 @@ All of the log file options can also be set in the configuration INI file. The
223224
option names are:
224225

225226
* ``log_file``
227+
* ``log_file_mode``
226228
* ``log_file_level``
227229
* ``log_file_format``
228230
* ``log_file_date_format``
229231

230232
You can call ``set_log_path()`` to customize the log_file path dynamically. This functionality
231-
is considered **experimental**.
233+
is considered **experimental**. Note that ``set_log_path()`` respects the ``log_file_mode`` option.
232234

233235
.. _log_colors:
234236

src/_pytest/logging.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,13 @@ def add_option_ini(option, dest, default=None, type=None, **kwargs):
298298
default=None,
299299
help="Path to a file when logging will be written to",
300300
)
301+
add_option_ini(
302+
"--log-file-mode",
303+
dest="log_file_mode",
304+
default="w",
305+
choices=["w", "a"],
306+
help="Log file open mode",
307+
)
301308
add_option_ini(
302309
"--log-file-level",
303310
dest="log_file_level",
@@ -669,7 +676,10 @@ def __init__(self, config: Config) -> None:
669676
if not os.path.isdir(directory):
670677
os.makedirs(directory)
671678

672-
self.log_file_handler = _FileHandler(log_file, mode="w", encoding="UTF-8")
679+
self.log_file_mode = get_option_ini(config, "log_file_mode") or "w"
680+
self.log_file_handler = _FileHandler(
681+
log_file, mode=self.log_file_mode, encoding="UTF-8"
682+
)
673683
log_file_format = get_option_ini(config, "log_file_format", "log_format")
674684
log_file_date_format = get_option_ini(
675685
config, "log_file_date_format", "log_date_format"
@@ -746,7 +756,7 @@ def set_log_path(self, fname: str) -> None:
746756
fpath.parent.mkdir(exist_ok=True, parents=True)
747757

748758
# https://github.com/python/mypy/issues/11193
749-
stream: io.TextIOWrapper = fpath.open(mode="w", encoding="UTF-8") # type: ignore[assignment]
759+
stream: io.TextIOWrapper = fpath.open(mode=self.log_file_mode, encoding="UTF-8") # type: ignore[assignment]
750760
old_stream = self.log_file_handler.setStream(stream)
751761
if old_stream:
752762
old_stream.close()

testing/logging/test_reporting.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -661,6 +661,73 @@ def test_log_file(request):
661661
assert "This log message won't be shown" not in contents
662662

663663

664+
def test_log_file_mode_cli(pytester: Pytester) -> None:
665+
# Default log file level
666+
pytester.makepyfile(
667+
"""
668+
import pytest
669+
import logging
670+
def test_log_file(request):
671+
plugin = request.config.pluginmanager.getplugin('logging-plugin')
672+
assert plugin.log_file_handler.level == logging.WARNING
673+
logging.getLogger('catchlog').info("This log message won't be shown")
674+
logging.getLogger('catchlog').warning("This log message will be shown")
675+
print('PASSED')
676+
"""
677+
)
678+
679+
log_file = str(pytester.path.joinpath("pytest.log"))
680+
681+
with open(log_file, mode="w", encoding="utf-8") as wfh:
682+
wfh.write("A custom header\n")
683+
684+
result = pytester.runpytest(
685+
"-s",
686+
f"--log-file={log_file}",
687+
"--log-file-mode=a",
688+
"--log-file-level=WARNING",
689+
)
690+
691+
# fnmatch_lines does an assertion internally
692+
result.stdout.fnmatch_lines(["test_log_file_mode_cli.py PASSED"])
693+
694+
# make sure that we get a '0' exit code for the testsuite
695+
assert result.ret == 0
696+
assert os.path.isfile(log_file)
697+
with open(log_file, encoding="utf-8") as rfh:
698+
contents = rfh.read()
699+
assert "A custom header" in contents
700+
assert "This log message will be shown" in contents
701+
assert "This log message won't be shown" not in contents
702+
703+
704+
def test_log_file_mode_cli_invalid(pytester: Pytester) -> None:
705+
# Default log file level
706+
pytester.makepyfile(
707+
"""
708+
import pytest
709+
import logging
710+
def test_log_file(request):
711+
plugin = request.config.pluginmanager.getplugin('logging-plugin')
712+
assert plugin.log_file_handler.level == logging.WARNING
713+
logging.getLogger('catchlog').info("This log message won't be shown")
714+
logging.getLogger('catchlog').warning("This log message will be shown")
715+
"""
716+
)
717+
718+
log_file = str(pytester.path.joinpath("pytest.log"))
719+
720+
result = pytester.runpytest(
721+
"-s",
722+
f"--log-file={log_file}",
723+
"--log-file-mode=b",
724+
"--log-file-level=WARNING",
725+
)
726+
727+
# make sure that we get a '4' exit code for the testsuite
728+
assert result.ret == ExitCode.USAGE_ERROR
729+
730+
664731
def test_log_file_cli_level(pytester: Pytester) -> None:
665732
# Default log file level
666733
pytester.makepyfile(
@@ -741,6 +808,47 @@ def test_log_file(request):
741808
assert "This log message won't be shown" not in contents
742809

743810

811+
def test_log_file_mode_ini(pytester: Pytester) -> None:
812+
log_file = str(pytester.path.joinpath("pytest.log"))
813+
814+
pytester.makeini(
815+
f"""
816+
[pytest]
817+
log_file={log_file}
818+
log_file_mode=a
819+
log_file_level=WARNING
820+
"""
821+
)
822+
pytester.makepyfile(
823+
"""
824+
import pytest
825+
import logging
826+
def test_log_file(request):
827+
plugin = request.config.pluginmanager.getplugin('logging-plugin')
828+
assert plugin.log_file_handler.level == logging.WARNING
829+
logging.getLogger('catchlog').info("This log message won't be shown")
830+
logging.getLogger('catchlog').warning("This log message will be shown")
831+
print('PASSED')
832+
"""
833+
)
834+
835+
with open(log_file, mode="w", encoding="utf-8") as wfh:
836+
wfh.write("A custom header\n")
837+
838+
result = pytester.runpytest("-s")
839+
840+
# fnmatch_lines does an assertion internally
841+
result.stdout.fnmatch_lines(["test_log_file_mode_ini.py PASSED"])
842+
843+
assert result.ret == ExitCode.OK
844+
assert os.path.isfile(log_file)
845+
with open(log_file, encoding="utf-8") as rfh:
846+
contents = rfh.read()
847+
assert "A custom header" in contents
848+
assert "This log message will be shown" in contents
849+
assert "This log message won't be shown" not in contents
850+
851+
744852
def test_log_file_ini_level(pytester: Pytester) -> None:
745853
log_file = str(pytester.path.joinpath("pytest.log"))
746854

@@ -1060,6 +1168,66 @@ def test_second():
10601168
assert "message from test 2" in content
10611169

10621170

1171+
def test_log_set_path_with_log_file_mode(pytester: Pytester) -> None:
1172+
report_dir_base = str(pytester.path)
1173+
1174+
pytester.makeini(
1175+
"""
1176+
[pytest]
1177+
log_file_level = DEBUG
1178+
log_cli=true
1179+
log_file_mode=a
1180+
"""
1181+
)
1182+
pytester.makeconftest(
1183+
f"""
1184+
import os
1185+
import pytest
1186+
@pytest.hookimpl(wrapper=True, tryfirst=True)
1187+
def pytest_runtest_setup(item):
1188+
config = item.config
1189+
logging_plugin = config.pluginmanager.get_plugin("logging-plugin")
1190+
report_file = os.path.join({report_dir_base!r}, item._request.node.name)
1191+
logging_plugin.set_log_path(report_file)
1192+
return (yield)
1193+
"""
1194+
)
1195+
pytester.makepyfile(
1196+
"""
1197+
import logging
1198+
logger = logging.getLogger("testcase-logger")
1199+
def test_first():
1200+
logger.info("message from test 1")
1201+
assert True
1202+
1203+
def test_second():
1204+
logger.debug("message from test 2")
1205+
assert True
1206+
"""
1207+
)
1208+
1209+
test_first_log_file = os.path.join(report_dir_base, "test_first")
1210+
test_second_log_file = os.path.join(report_dir_base, "test_second")
1211+
with open(test_first_log_file, mode="w", encoding="utf-8") as wfh:
1212+
wfh.write("A custom header for test 1\n")
1213+
1214+
with open(test_second_log_file, mode="w", encoding="utf-8") as wfh:
1215+
wfh.write("A custom header for test 2\n")
1216+
1217+
result = pytester.runpytest()
1218+
assert result.ret == ExitCode.OK
1219+
1220+
with open(test_first_log_file, encoding="utf-8") as rfh:
1221+
content = rfh.read()
1222+
assert "A custom header for test 1" in content
1223+
assert "message from test 1" in content
1224+
1225+
with open(test_second_log_file, encoding="utf-8") as rfh:
1226+
content = rfh.read()
1227+
assert "A custom header for test 2" in content
1228+
assert "message from test 2" in content
1229+
1230+
10631231
def test_colored_captured_log(pytester: Pytester) -> None:
10641232
"""Test that the level names of captured log messages of a failing test
10651233
are colored."""

0 commit comments

Comments
 (0)