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:
		
							parent
							
								
									8a410d0ba6
								
							
						
					
					
						commit
						c5c729e27a
					
				
							
								
								
									
										1
									
								
								AUTHORS
								
								
								
								
							
							
						
						
									
										1
									
								
								AUTHORS
								
								
								
								
							| 
						 | 
					@ -56,6 +56,7 @@ Babak Keyvani
 | 
				
			||||||
Barney Gale
 | 
					Barney Gale
 | 
				
			||||||
Ben Brown
 | 
					Ben Brown
 | 
				
			||||||
Ben Gartner
 | 
					Ben Gartner
 | 
				
			||||||
 | 
					Ben Leith
 | 
				
			||||||
Ben Webb
 | 
					Ben Webb
 | 
				
			||||||
Benjamin Peterson
 | 
					Benjamin Peterson
 | 
				
			||||||
Benjamin Schubert
 | 
					Benjamin Schubert
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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.
 | 
				
			||||||
| 
						 | 
					@ -206,8 +206,9 @@ option names are:
 | 
				
			||||||
* ``log_cli_date_format``
 | 
					* ``log_cli_date_format``
 | 
				
			||||||
 | 
					
 | 
				
			||||||
If you need to record the whole test suite logging calls to a file, you can pass
 | 
					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.
 | 
					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
 | 
					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.
 | 
					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:
 | 
					option names are:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
* ``log_file``
 | 
					* ``log_file``
 | 
				
			||||||
 | 
					* ``log_file_mode``
 | 
				
			||||||
* ``log_file_level``
 | 
					* ``log_file_level``
 | 
				
			||||||
* ``log_file_format``
 | 
					* ``log_file_format``
 | 
				
			||||||
* ``log_file_date_format``
 | 
					* ``log_file_date_format``
 | 
				
			||||||
 | 
					
 | 
				
			||||||
You can call ``set_log_path()`` to customize the log_file path dynamically. This functionality
 | 
					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:
 | 
					.. _log_colors:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -298,6 +298,13 @@ def pytest_addoption(parser: Parser) -> None:
 | 
				
			||||||
        default=None,
 | 
					        default=None,
 | 
				
			||||||
        help="Path to a file when logging will be written to",
 | 
					        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(
 | 
					    add_option_ini(
 | 
				
			||||||
        "--log-file-level",
 | 
					        "--log-file-level",
 | 
				
			||||||
        dest="log_file_level",
 | 
					        dest="log_file_level",
 | 
				
			||||||
| 
						 | 
					@ -669,7 +676,10 @@ class LoggingPlugin:
 | 
				
			||||||
            if not os.path.isdir(directory):
 | 
					            if not os.path.isdir(directory):
 | 
				
			||||||
                os.makedirs(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_format = get_option_ini(config, "log_file_format", "log_format")
 | 
				
			||||||
        log_file_date_format = get_option_ini(
 | 
					        log_file_date_format = get_option_ini(
 | 
				
			||||||
            config, "log_file_date_format", "log_date_format"
 | 
					            config, "log_file_date_format", "log_date_format"
 | 
				
			||||||
| 
						 | 
					@ -746,7 +756,7 @@ class LoggingPlugin:
 | 
				
			||||||
            fpath.parent.mkdir(exist_ok=True, parents=True)
 | 
					            fpath.parent.mkdir(exist_ok=True, parents=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # https://github.com/python/mypy/issues/11193
 | 
					        # 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)
 | 
					        old_stream = self.log_file_handler.setStream(stream)
 | 
				
			||||||
        if old_stream:
 | 
					        if old_stream:
 | 
				
			||||||
            old_stream.close()
 | 
					            old_stream.close()
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -661,6 +661,73 @@ def test_log_file_cli(pytester: Pytester) -> None:
 | 
				
			||||||
        assert "This log message won't be shown" not in contents
 | 
					        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:
 | 
					def test_log_file_cli_level(pytester: Pytester) -> None:
 | 
				
			||||||
    # Default log file level
 | 
					    # Default log file level
 | 
				
			||||||
    pytester.makepyfile(
 | 
					    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
 | 
					        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:
 | 
					def test_log_file_ini_level(pytester: Pytester) -> None:
 | 
				
			||||||
    log_file = str(pytester.path.joinpath("pytest.log"))
 | 
					    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
 | 
					        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:
 | 
					def test_colored_captured_log(pytester: Pytester) -> None:
 | 
				
			||||||
    """Test that the level names of captured log messages of a failing test
 | 
					    """Test that the level names of captured log messages of a failing test
 | 
				
			||||||
    are colored."""
 | 
					    are colored."""
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
		Reference in New Issue