diff --git a/coverage/cmdline.py b/coverage/cmdline.py index 998e6b398..115e6741e 100644 --- a/coverage/cmdline.py +++ b/coverage/cmdline.py @@ -10,9 +10,11 @@ import os import os.path import shlex +import signal import sys import textwrap import traceback +import types from typing import cast, Any, NoReturn @@ -188,6 +190,16 @@ class Opts: "'pyproject.toml' are tried. [env: COVERAGE_RCFILE]" ), ) + save_signal = optparse.make_option( + '', '--save-signal', action='store', metavar='SAVE_SIGNAL', + choices = ['USR1', 'USR2'], + help=( + "Define a system signal that will trigger coverage report save operation. " + + "It is important that target script do not intercept this signal. " + + "Currently supported options are: USR1, USR2. " + + "This feature does not work on Windows." + ), + ) show_contexts = optparse.make_option( "--show-contexts", action="store_true", help="Show contexts for covered lines.", @@ -228,7 +240,6 @@ class Opts: help="Display version information and exit.", ) - class CoverageOptionParser(optparse.OptionParser): """Base OptionParser for coverage.py. @@ -264,6 +275,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: pylib=None, quiet=None, rcfile=True, + save_signal=None, show_contexts=None, show_missing=None, skip_covered=None, @@ -523,6 +535,7 @@ def get_prog_name(self) -> str: Opts.omit, Opts.pylib, Opts.parallel_mode, + Opts.save_signal, Opts.source, Opts.timid, ] + GLOBAL_ARGS, @@ -807,6 +820,11 @@ def do_help( return False + def do_signal_save(self, _signum: int, _frame: types.FrameType | None) -> None: + """ Signal handler to save coverage report """ + print("Saving coverage data ...") + self.coverage.save() + def do_run(self, options: optparse.Values, args: list[str]) -> int: """Implementation of 'coverage run'.""" @@ -851,6 +869,18 @@ def do_run(self, options: optparse.Values, args: list[str]) -> int: if options.append: self.coverage.load() + if options.save_signal: + if env.WINDOWS: + show_help("Signals are not supported in Windows environment.") + return ERR + if options.save_signal.upper() == 'USR1': + signal.signal(signal.SIGUSR1, self.do_signal_save) + elif options.save_signal.upper() == 'USR2': + signal.signal(signal.SIGUSR2, self.do_signal_save) + else: + show_help(f"Unsupported signal for save coverage report: {options.save_signal}") + return ERR + # Run the script. self.coverage.start() code_ran = True diff --git a/doc/cmd.rst b/doc/cmd.rst index ee40a4fe5..0446b1aa6 100644 --- a/doc/cmd.rst +++ b/doc/cmd.rst @@ -133,6 +133,12 @@ There are many options: -p, --parallel-mode Append the machine name, process id and random number to the data file name to simplify collecting data from many processes. + --save-signal=SAVE_SIGNAL + Define a system signal that will trigger coverage + report save operation. It is important that target + script do not intercept this signal. Currently + supported options are: USR1, USR2. This feature does + not work on Windows. --source=SRC1,SRC2,... A list of directories or importable names of code to measure. @@ -143,7 +149,7 @@ There are many options: --rcfile=RCFILE Specify configuration file. By default '.coveragerc', 'setup.cfg', 'tox.ini', and 'pyproject.toml' are tried. [env: COVERAGE_RCFILE] -.. [[[end]]] (sum: saD//ido/B) +.. [[[end]]] (sum: X8Kbvdq2+f) If you want :ref:`branch coverage ` measurement, use the ``--branch`` flag. Otherwise only statement coverage is measured. @@ -215,6 +221,9 @@ and may change in the future. These options can also be set in the :ref:`config_run` section of your .coveragerc file. +In case if you are specifying ``--dump_signal``, please make sure that +your target script doesn't intercept this signal. Otherwise the coverage +reports will not be generated. .. _cmd_warnings: diff --git a/tests/test_process.py b/tests/test_process.py index 7633f69be..1fc11431f 100644 --- a/tests/test_process.py +++ b/tests/test_process.py @@ -687,6 +687,51 @@ def test_module_name(self) -> None: out = self.run_command("python -m coverage") assert "Use 'coverage help' for help" in out + @pytest.mark.skipif(env.WINDOWS, reason="This test is not for Windows") + def test_save_signal(self) -> None: + test_file = "dummy_hello.py" + self.assert_doesnt_exist(".coverage") + self.make_file(test_file, """\ + import os + import signal + + print(f"Sending SIGUSR1 to process {os.getpid()}") + os.kill(os.getpid(), signal.SIGUSR1) + os.kill(os.getpid(), signal.SIGKILL) + + print('Done and goodbye') + """) + covered_lines = 4 + self.run_command(f"coverage run --save-signal USR1 {test_file}") + self.assert_exists(".coverage") + data = coverage.CoverageData() + data.read() + assert line_counts(data)[test_file] == covered_lines + out = self.run_command("coverage report") + assert out == textwrap.dedent("""\ + Name Stmts Miss Cover + ------------------------------------ + dummy_hello.py 6 2 67% + ------------------------------------ + TOTAL 6 2 67% + """) + + # Negative test for signal + @pytest.mark.skipif(env.WINDOWS, reason="This test is not for Windows") + def test_save_signal_no_send(self) -> None: + test_file = "dummy_hello.py" + self.assert_doesnt_exist(".coverage") + self.make_file(test_file, """\ + import os + import signal + + os.kill(os.getpid(), signal.SIGKILL) + + print('Done and goodbye') + """) + self.run_command(f"coverage run --save-signal USR1 {test_file}") + self.assert_doesnt_exist(".coverage") + TRY_EXECFILE = os.path.join(os.path.dirname(__file__), "modules/process_test/try_execfile.py")