From 5f65d87b14245d4523bc866a75a16b6c55a7ce70 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Sun, 22 Jan 2023 09:08:09 -0500 Subject: feat: the debug output file can be specified in the config file. #1319 --- CHANGES.rst | 6 +++++ coverage/config.py | 2 ++ coverage/control.py | 6 ++--- coverage/debug.py | 70 ++++++++++++++++++++++++++++++++++++----------------- doc/cmd.rst | 11 +++++---- doc/config.rst | 9 +++++++ tests/test_debug.py | 66 ++++++++++++++++++++++++++++++++++++++++---------- 7 files changed, 126 insertions(+), 44 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index d845d884..185f20bf 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -20,8 +20,14 @@ development at the same time, such as 4.5.x and 5.0. Unreleased ---------- +- Added: the debug output file can now be specified with ``[run] debug_file`` + in the configuration file. Closes `issue 1319`_. + - Typing: all product and test code has type annotations. +.. _issue 1319: https://github.com/nedbat/coveragepy/issues/1319 + + .. scriv-start-here .. _changes_7-0-5: diff --git a/coverage/config.py b/coverage/config.py index 046c1b00..e15d2aff 100644 --- a/coverage/config.py +++ b/coverage/config.py @@ -199,6 +199,7 @@ class CoverageConfig(TConfigurable, TPluginConfig): self.cover_pylib = False self.data_file = ".coverage" self.debug: List[str] = [] + self.debug_file: Optional[str] = None self.disable_warnings: List[str] = [] self.dynamic_context: Optional[str] = None self.parallel = False @@ -375,6 +376,7 @@ class CoverageConfig(TConfigurable, TPluginConfig): ('cover_pylib', 'run:cover_pylib', 'boolean'), ('data_file', 'run:data_file'), ('debug', 'run:debug', 'list'), + ('debug_file', 'run:debug_file'), ('disable_warnings', 'run:disable_warnings', 'list'), ('dynamic_context', 'run:dynamic_context'), ('parallel', 'run:parallel', 'boolean'), diff --git a/coverage/control.py b/coverage/control.py index d37c77e3..78e0c70e 100644 --- a/coverage/control.py +++ b/coverage/control.py @@ -303,10 +303,8 @@ class Coverage(TConfigurable): self._inited = True - # Create and configure the debugging controller. COVERAGE_DEBUG_FILE - # is an environment variable, the name of a file to append debug logs - # to. - self._debug = DebugControl(self.config.debug, self._debug_file) + # Create and configure the debugging controller. + self._debug = DebugControl(self.config.debug, self._debug_file, self.config.debug_file) if "multiprocessing" in (self.config.concurrency or ()): # Multi-processing uses parallel for the subprocesses, so also use diff --git a/coverage/debug.py b/coverage/debug.py index 29dd1e7f..d1f27cd8 100644 --- a/coverage/debug.py +++ b/coverage/debug.py @@ -39,7 +39,12 @@ class DebugControl: show_repr_attr = False # For AutoReprMixin - def __init__(self, options: Iterable[str], output: Optional[IO[str]]) -> None: + def __init__( + self, + options: Iterable[str], + output: Optional[IO[str]], + file_name: Optional[str] = None, + ) -> None: """Configure the options and output file for debugging.""" self.options = list(options) + FORCED_DEBUG self.suppress_callers = False @@ -49,6 +54,7 @@ class DebugControl: filters.append(add_pid_and_tid) self.output = DebugOutputFile.get_one( output, + file_name=file_name, show_process=self.should('process'), filters=filters, ) @@ -306,13 +312,11 @@ class DebugOutputFile: # pragma: debugging if hasattr(os, 'getppid'): self.write(f"New process: pid: {os.getpid()!r}, parent pid: {os.getppid()!r}\n") - SYS_MOD_NAME = '$coverage.debug.DebugOutputFile.the_one' - SINGLETON_ATTR = 'the_one_and_is_interim' - @classmethod def get_one( cls, fileobj: Optional[IO[str]] = None, + file_name: Optional[str] = None, show_process: bool = True, filters: Iterable[Callable[[str], str]] = (), interim: bool = False, @@ -321,9 +325,9 @@ class DebugOutputFile: # pragma: debugging If `fileobj` is provided, then a new DebugOutputFile is made with it. - If `fileobj` isn't provided, then a file is chosen - (COVERAGE_DEBUG_FILE, or stderr), and a process-wide singleton - DebugOutputFile is made. + If `fileobj` isn't provided, then a file is chosen (`file_name` if + provided, or COVERAGE_DEBUG_FILE, or stderr), and a process-wide + singleton DebugOutputFile is made. `show_process` controls whether the debug file adds process-level information, and filters is a list of other message filters to apply. @@ -338,27 +342,49 @@ class DebugOutputFile: # pragma: debugging # Make DebugOutputFile around the fileobj passed. return cls(fileobj, show_process, filters) - # Because of the way igor.py deletes and re-imports modules, - # this class can be defined more than once. But we really want - # a process-wide singleton. So stash it in sys.modules instead of - # on a class attribute. Yes, this is aggressively gross. - singleton_module = sys.modules.get(cls.SYS_MOD_NAME) - the_one, is_interim = getattr(singleton_module, cls.SINGLETON_ATTR, (None, True)) + the_one, is_interim = cls._get_singleton_data() if the_one is None or is_interim: - if fileobj is None: - debug_file_name = os.environ.get("COVERAGE_DEBUG_FILE", FORCED_DEBUG_FILE) - if debug_file_name in ("stdout", "stderr"): - fileobj = getattr(sys, debug_file_name) - elif debug_file_name: - fileobj = open(debug_file_name, "a") + if file_name is not None: + fileobj = open(file_name, "a", encoding="utf-8") + else: + file_name = os.environ.get("COVERAGE_DEBUG_FILE", FORCED_DEBUG_FILE) + if file_name in ("stdout", "stderr"): + fileobj = getattr(sys, file_name) + elif file_name: + fileobj = open(file_name, "a", encoding="utf-8") else: fileobj = sys.stderr the_one = cls(fileobj, show_process, filters) - singleton_module = types.ModuleType(cls.SYS_MOD_NAME) - setattr(singleton_module, cls.SINGLETON_ATTR, (the_one, interim)) - sys.modules[cls.SYS_MOD_NAME] = singleton_module + cls._set_singleton_data(the_one, interim) return the_one + # Because of the way igor.py deletes and re-imports modules, + # this class can be defined more than once. But we really want + # a process-wide singleton. So stash it in sys.modules instead of + # on a class attribute. Yes, this is aggressively gross. + + SYS_MOD_NAME = '$coverage.debug.DebugOutputFile.the_one' + SINGLETON_ATTR = 'the_one_and_is_interim' + + @classmethod + def _set_singleton_data(cls, the_one: DebugOutputFile, interim: bool) -> None: + """Set the one DebugOutputFile to rule them all.""" + singleton_module = types.ModuleType(cls.SYS_MOD_NAME) + setattr(singleton_module, cls.SINGLETON_ATTR, (the_one, interim)) + sys.modules[cls.SYS_MOD_NAME] = singleton_module + + @classmethod + def _get_singleton_data(cls) -> Tuple[Optional[DebugOutputFile], bool]: + """Get the one DebugOutputFile.""" + singleton_module = sys.modules.get(cls.SYS_MOD_NAME) + return getattr(singleton_module, cls.SINGLETON_ATTR, (None, True)) + + @classmethod + def _del_singleton_data(cls) -> None: + """Delete the one DebugOutputFile, just for tests to use.""" + if cls.SYS_MOD_NAME in sys.modules: + del sys.modules[cls.SYS_MOD_NAME] + def write(self, text: str) -> None: """Just like file.write, but filter through all our filters.""" assert self.outfile is not None diff --git a/doc/cmd.rst b/doc/cmd.rst index b86650a0..0704e940 100644 --- a/doc/cmd.rst +++ b/doc/cmd.rst @@ -1056,8 +1056,9 @@ Debug options can also be set with the ``COVERAGE_DEBUG`` environment variable, a comma-separated list of these options, or in the :ref:`config_run_debug` section of the .coveragerc file. -The debug output goes to stderr, unless the ``COVERAGE_DEBUG_FILE`` environment -variable names a different file, which will be appended to. This can be useful -because many test runners capture output, which could hide important details. -``COVERAGE_DEBUG_FILE`` accepts the special names ``stdout`` and ``stderr`` to -write to those destinations. +The debug output goes to stderr, unless the :ref:`config_run_debug_file` +setting or the ``COVERAGE_DEBUG_FILE`` environment variable names a different +file, which will be appended to. This can be useful because many test runners +capture output, which could hide important details. ``COVERAGE_DEBUG_FILE`` +accepts the special names ``stdout`` and ``stderr`` to write to those +destinations. diff --git a/doc/config.rst b/doc/config.rst index 90949506..8e3d885b 100644 --- a/doc/config.rst +++ b/doc/config.rst @@ -203,6 +203,15 @@ include a short string at the end, the name of the warning. See ` for details. +.. _config_run_debug_file: + +[run] debug_file +................ + +(string) A file name to write debug output to. See :ref:`the run --debug +option ` for details. + + .. _config_run_dynamic_context: [run] dynamic_context diff --git a/tests/test_debug.py b/tests/test_debug.py index 38b70f28..c2d9efe3 100644 --- a/tests/test_debug.py +++ b/tests/test_debug.py @@ -17,8 +17,10 @@ import pytest import coverage from coverage import env -from coverage.debug import filter_text, info_formatter, info_header, short_id, short_stack -from coverage.debug import clipped_repr +from coverage.debug import ( + DebugOutputFile, + clipped_repr, filter_text, info_formatter, info_header, short_id, short_stack, +) from tests.coveragetest import CoverageTest from tests.helpers import re_line, re_lines, re_lines_text @@ -186,17 +188,7 @@ class DebugTraceTest(CoverageTest): def test_debug_sys(self) -> None: out_text = self.f1_debug_output(["sys"]) - - labels = """ - coverage_version coverage_module coverage_paths stdlib_paths third_party_paths - tracer configs_attempted config_file configs_read data_file - python platform implementation executable - pid cwd path environment command_line cover_match pylib_match - """.split() - for label in labels: - label_pat = fr"^\s*{label}: " - msg = f"Incorrect lines for {label!r}" - assert 1 == len(re_lines(label_pat, out_text)), msg + assert_good_debug_sys(out_text) def test_debug_sys_ctracer(self) -> None: out_text = self.f1_debug_output(["sys"]) @@ -216,6 +208,54 @@ class DebugTraceTest(CoverageTest): assert vtuple[:5] == sys.version_info +def assert_good_debug_sys(out_text: str) -> None: + """Assert that `str` is good output for debug=sys.""" + labels = """ + coverage_version coverage_module coverage_paths stdlib_paths third_party_paths + tracer configs_attempted config_file configs_read data_file + python platform implementation executable + pid cwd path environment command_line cover_match pylib_match + """.split() + for label in labels: + label_pat = fr"^\s*{label}: " + msg = f"Incorrect lines for {label!r}" + assert 1 == len(re_lines(label_pat, out_text)), msg + + +class DebugOutputTest(CoverageTest): + """Tests that we can direct debug output where we want.""" + + def setUp(self) -> None: + super().setUp() + # DebugOutputFile aggressively tries to start just one output file. We + # need to manually force it to make a new one. + DebugOutputFile._del_singleton_data() + + def debug_sys(self) -> None: + """Run just enough coverage to get full debug=sys output.""" + cov = coverage.Coverage(debug=["sys"]) + cov.start() + cov.stop() + + def test_stderr_default(self) -> None: + self.debug_sys() + assert_good_debug_sys(self.stderr()) + + def test_envvar(self) -> None: + self.set_environ("COVERAGE_DEBUG_FILE", "debug.out") + self.debug_sys() + assert self.stderr() == "" + with open("debug.out") as f: + assert_good_debug_sys(f.read()) + + def test_config_file(self) -> None: + self.make_file(".coveragerc", "[run]\ndebug_file = lotsa_info.txt") + self.debug_sys() + assert self.stderr() == "" + with open("lotsa_info.txt") as f: + assert_good_debug_sys(f.read()) + + def f_one(*args: Any, **kwargs: Any) -> str: """First of the chain of functions for testing `short_stack`.""" return f_two(*args, **kwargs) -- cgit v1.2.1