Skip to content

Commit

Permalink
feat: the debug output file can be specified in the config file. #1319
Browse files Browse the repository at this point in the history
  • Loading branch information
nedbat committed Jan 22, 2023
1 parent c51ac46 commit 5f65d87
Show file tree
Hide file tree
Showing 7 changed files with 126 additions and 44 deletions.
6 changes: 6 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions coverage/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ def __init__(self) -> None:
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
Expand Down Expand Up @@ -375,6 +376,7 @@ def copy(self) -> CoverageConfig:
('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'),
Expand Down
6 changes: 2 additions & 4 deletions coverage/control.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,10 +303,8 @@ def _init(self) -> None:

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
Expand Down
70 changes: 48 additions & 22 deletions coverage/debug.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -49,6 +54,7 @@ def __init__(self, options: Iterable[str], output: Optional[IO[str]]) -> None:
filters.append(add_pid_and_tid)
self.output = DebugOutputFile.get_one(
output,
file_name=file_name,
show_process=self.should('process'),
filters=filters,
)
Expand Down Expand Up @@ -306,13 +312,11 @@ def __init__(
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,
Expand All @@ -321,9 +325,9 @@ def get_one(
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.
Expand All @@ -338,27 +342,49 @@ def get_one(
# 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
Expand Down
11 changes: 6 additions & 5 deletions doc/cmd.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
9 changes: 9 additions & 0 deletions doc/config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 53 additions & 13 deletions tests/test_debug.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@

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
Expand Down Expand Up @@ -186,17 +188,7 @@ def test_debug_config(self) -> None:

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"])
Expand All @@ -216,6 +208,54 @@ def test_debug_pybehave(self) -> None:
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)
Expand Down

0 comments on commit 5f65d87

Please sign in to comment.