summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorNed Batchelder <ned@nedbatchelder.com>2023-01-22 09:08:09 -0500
committerNed Batchelder <ned@nedbatchelder.com>2023-01-22 10:58:53 -0500
commit5f65d87b14245d4523bc866a75a16b6c55a7ce70 (patch)
treebd4d404c80baa1c7e6e25e2335ffcee52702ee66
parentc51ac463f07e31c87b20f50bd7e6445e4e4e83a2 (diff)
downloadpython-coveragepy-git-5f65d87b14245d4523bc866a75a16b6c55a7ce70.tar.gz
feat: the debug output file can be specified in the config file. #1319
-rw-r--r--CHANGES.rst6
-rw-r--r--coverage/config.py2
-rw-r--r--coverage/control.py6
-rw-r--r--coverage/debug.py70
-rw-r--r--doc/cmd.rst11
-rw-r--r--doc/config.rst9
-rw-r--r--tests/test_debug.py66
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
<cmd_run_debug>` for details.
+.. _config_run_debug_file:
+
+[run] debug_file
+................
+
+(string) A file name to write debug output to. See :ref:`the run --debug
+option <cmd_run_debug>` 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)