Skip to content

Commit 5f65d87

Browse files
committed
feat: the debug output file can be specified in the config file. #1319
1 parent c51ac46 commit 5f65d87

File tree

7 files changed

+126
-44
lines changed

7 files changed

+126
-44
lines changed

CHANGES.rst

+6
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,14 @@ development at the same time, such as 4.5.x and 5.0.
2020
Unreleased
2121
----------
2222

23+
- Added: the debug output file can now be specified with ``[run] debug_file``
24+
in the configuration file. Closes `issue 1319`_.
25+
2326
- Typing: all product and test code has type annotations.
2427

28+
.. _issue 1319: https://github.com/nedbat/coveragepy/issues/1319
29+
30+
2531
.. scriv-start-here
2632
2733
.. _changes_7-0-5:

coverage/config.py

+2
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ def __init__(self) -> None:
199199
self.cover_pylib = False
200200
self.data_file = ".coverage"
201201
self.debug: List[str] = []
202+
self.debug_file: Optional[str] = None
202203
self.disable_warnings: List[str] = []
203204
self.dynamic_context: Optional[str] = None
204205
self.parallel = False
@@ -375,6 +376,7 @@ def copy(self) -> CoverageConfig:
375376
('cover_pylib', 'run:cover_pylib', 'boolean'),
376377
('data_file', 'run:data_file'),
377378
('debug', 'run:debug', 'list'),
379+
('debug_file', 'run:debug_file'),
378380
('disable_warnings', 'run:disable_warnings', 'list'),
379381
('dynamic_context', 'run:dynamic_context'),
380382
('parallel', 'run:parallel', 'boolean'),

coverage/control.py

+2-4
Original file line numberDiff line numberDiff line change
@@ -303,10 +303,8 @@ def _init(self) -> None:
303303

304304
self._inited = True
305305

306-
# Create and configure the debugging controller. COVERAGE_DEBUG_FILE
307-
# is an environment variable, the name of a file to append debug logs
308-
# to.
309-
self._debug = DebugControl(self.config.debug, self._debug_file)
306+
# Create and configure the debugging controller.
307+
self._debug = DebugControl(self.config.debug, self._debug_file, self.config.debug_file)
310308

311309
if "multiprocessing" in (self.config.concurrency or ()):
312310
# Multi-processing uses parallel for the subprocesses, so also use

coverage/debug.py

+48-22
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,12 @@ class DebugControl:
3939

4040
show_repr_attr = False # For AutoReprMixin
4141

42-
def __init__(self, options: Iterable[str], output: Optional[IO[str]]) -> None:
42+
def __init__(
43+
self,
44+
options: Iterable[str],
45+
output: Optional[IO[str]],
46+
file_name: Optional[str] = None,
47+
) -> None:
4348
"""Configure the options and output file for debugging."""
4449
self.options = list(options) + FORCED_DEBUG
4550
self.suppress_callers = False
@@ -49,6 +54,7 @@ def __init__(self, options: Iterable[str], output: Optional[IO[str]]) -> None:
4954
filters.append(add_pid_and_tid)
5055
self.output = DebugOutputFile.get_one(
5156
output,
57+
file_name=file_name,
5258
show_process=self.should('process'),
5359
filters=filters,
5460
)
@@ -306,13 +312,11 @@ def __init__(
306312
if hasattr(os, 'getppid'):
307313
self.write(f"New process: pid: {os.getpid()!r}, parent pid: {os.getppid()!r}\n")
308314

309-
SYS_MOD_NAME = '$coverage.debug.DebugOutputFile.the_one'
310-
SINGLETON_ATTR = 'the_one_and_is_interim'
311-
312315
@classmethod
313316
def get_one(
314317
cls,
315318
fileobj: Optional[IO[str]] = None,
319+
file_name: Optional[str] = None,
316320
show_process: bool = True,
317321
filters: Iterable[Callable[[str], str]] = (),
318322
interim: bool = False,
@@ -321,9 +325,9 @@ def get_one(
321325
322326
If `fileobj` is provided, then a new DebugOutputFile is made with it.
323327
324-
If `fileobj` isn't provided, then a file is chosen
325-
(COVERAGE_DEBUG_FILE, or stderr), and a process-wide singleton
326-
DebugOutputFile is made.
328+
If `fileobj` isn't provided, then a file is chosen (`file_name` if
329+
provided, or COVERAGE_DEBUG_FILE, or stderr), and a process-wide
330+
singleton DebugOutputFile is made.
327331
328332
`show_process` controls whether the debug file adds process-level
329333
information, and filters is a list of other message filters to apply.
@@ -338,27 +342,49 @@ def get_one(
338342
# Make DebugOutputFile around the fileobj passed.
339343
return cls(fileobj, show_process, filters)
340344

341-
# Because of the way igor.py deletes and re-imports modules,
342-
# this class can be defined more than once. But we really want
343-
# a process-wide singleton. So stash it in sys.modules instead of
344-
# on a class attribute. Yes, this is aggressively gross.
345-
singleton_module = sys.modules.get(cls.SYS_MOD_NAME)
346-
the_one, is_interim = getattr(singleton_module, cls.SINGLETON_ATTR, (None, True))
345+
the_one, is_interim = cls._get_singleton_data()
347346
if the_one is None or is_interim:
348-
if fileobj is None:
349-
debug_file_name = os.environ.get("COVERAGE_DEBUG_FILE", FORCED_DEBUG_FILE)
350-
if debug_file_name in ("stdout", "stderr"):
351-
fileobj = getattr(sys, debug_file_name)
352-
elif debug_file_name:
353-
fileobj = open(debug_file_name, "a")
347+
if file_name is not None:
348+
fileobj = open(file_name, "a", encoding="utf-8")
349+
else:
350+
file_name = os.environ.get("COVERAGE_DEBUG_FILE", FORCED_DEBUG_FILE)
351+
if file_name in ("stdout", "stderr"):
352+
fileobj = getattr(sys, file_name)
353+
elif file_name:
354+
fileobj = open(file_name, "a", encoding="utf-8")
354355
else:
355356
fileobj = sys.stderr
356357
the_one = cls(fileobj, show_process, filters)
357-
singleton_module = types.ModuleType(cls.SYS_MOD_NAME)
358-
setattr(singleton_module, cls.SINGLETON_ATTR, (the_one, interim))
359-
sys.modules[cls.SYS_MOD_NAME] = singleton_module
358+
cls._set_singleton_data(the_one, interim)
360359
return the_one
361360

361+
# Because of the way igor.py deletes and re-imports modules,
362+
# this class can be defined more than once. But we really want
363+
# a process-wide singleton. So stash it in sys.modules instead of
364+
# on a class attribute. Yes, this is aggressively gross.
365+
366+
SYS_MOD_NAME = '$coverage.debug.DebugOutputFile.the_one'
367+
SINGLETON_ATTR = 'the_one_and_is_interim'
368+
369+
@classmethod
370+
def _set_singleton_data(cls, the_one: DebugOutputFile, interim: bool) -> None:
371+
"""Set the one DebugOutputFile to rule them all."""
372+
singleton_module = types.ModuleType(cls.SYS_MOD_NAME)
373+
setattr(singleton_module, cls.SINGLETON_ATTR, (the_one, interim))
374+
sys.modules[cls.SYS_MOD_NAME] = singleton_module
375+
376+
@classmethod
377+
def _get_singleton_data(cls) -> Tuple[Optional[DebugOutputFile], bool]:
378+
"""Get the one DebugOutputFile."""
379+
singleton_module = sys.modules.get(cls.SYS_MOD_NAME)
380+
return getattr(singleton_module, cls.SINGLETON_ATTR, (None, True))
381+
382+
@classmethod
383+
def _del_singleton_data(cls) -> None:
384+
"""Delete the one DebugOutputFile, just for tests to use."""
385+
if cls.SYS_MOD_NAME in sys.modules:
386+
del sys.modules[cls.SYS_MOD_NAME]
387+
362388
def write(self, text: str) -> None:
363389
"""Just like file.write, but filter through all our filters."""
364390
assert self.outfile is not None

doc/cmd.rst

+6-5
Original file line numberDiff line numberDiff line change
@@ -1056,8 +1056,9 @@ Debug options can also be set with the ``COVERAGE_DEBUG`` environment variable,
10561056
a comma-separated list of these options, or in the :ref:`config_run_debug`
10571057
section of the .coveragerc file.
10581058

1059-
The debug output goes to stderr, unless the ``COVERAGE_DEBUG_FILE`` environment
1060-
variable names a different file, which will be appended to. This can be useful
1061-
because many test runners capture output, which could hide important details.
1062-
``COVERAGE_DEBUG_FILE`` accepts the special names ``stdout`` and ``stderr`` to
1063-
write to those destinations.
1059+
The debug output goes to stderr, unless the :ref:`config_run_debug_file`
1060+
setting or the ``COVERAGE_DEBUG_FILE`` environment variable names a different
1061+
file, which will be appended to. This can be useful because many test runners
1062+
capture output, which could hide important details. ``COVERAGE_DEBUG_FILE``
1063+
accepts the special names ``stdout`` and ``stderr`` to write to those
1064+
destinations.

doc/config.rst

+9
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,15 @@ include a short string at the end, the name of the warning. See
203203
<cmd_run_debug>` for details.
204204

205205

206+
.. _config_run_debug_file:
207+
208+
[run] debug_file
209+
................
210+
211+
(string) A file name to write debug output to. See :ref:`the run --debug
212+
option <cmd_run_debug>` for details.
213+
214+
206215
.. _config_run_dynamic_context:
207216

208217
[run] dynamic_context

tests/test_debug.py

+53-13
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@
1717

1818
import coverage
1919
from coverage import env
20-
from coverage.debug import filter_text, info_formatter, info_header, short_id, short_stack
21-
from coverage.debug import clipped_repr
20+
from coverage.debug import (
21+
DebugOutputFile,
22+
clipped_repr, filter_text, info_formatter, info_header, short_id, short_stack,
23+
)
2224

2325
from tests.coveragetest import CoverageTest
2426
from tests.helpers import re_line, re_lines, re_lines_text
@@ -186,17 +188,7 @@ def test_debug_config(self) -> None:
186188

187189
def test_debug_sys(self) -> None:
188190
out_text = self.f1_debug_output(["sys"])
189-
190-
labels = """
191-
coverage_version coverage_module coverage_paths stdlib_paths third_party_paths
192-
tracer configs_attempted config_file configs_read data_file
193-
python platform implementation executable
194-
pid cwd path environment command_line cover_match pylib_match
195-
""".split()
196-
for label in labels:
197-
label_pat = fr"^\s*{label}: "
198-
msg = f"Incorrect lines for {label!r}"
199-
assert 1 == len(re_lines(label_pat, out_text)), msg
191+
assert_good_debug_sys(out_text)
200192

201193
def test_debug_sys_ctracer(self) -> None:
202194
out_text = self.f1_debug_output(["sys"])
@@ -216,6 +208,54 @@ def test_debug_pybehave(self) -> None:
216208
assert vtuple[:5] == sys.version_info
217209

218210

211+
def assert_good_debug_sys(out_text: str) -> None:
212+
"""Assert that `str` is good output for debug=sys."""
213+
labels = """
214+
coverage_version coverage_module coverage_paths stdlib_paths third_party_paths
215+
tracer configs_attempted config_file configs_read data_file
216+
python platform implementation executable
217+
pid cwd path environment command_line cover_match pylib_match
218+
""".split()
219+
for label in labels:
220+
label_pat = fr"^\s*{label}: "
221+
msg = f"Incorrect lines for {label!r}"
222+
assert 1 == len(re_lines(label_pat, out_text)), msg
223+
224+
225+
class DebugOutputTest(CoverageTest):
226+
"""Tests that we can direct debug output where we want."""
227+
228+
def setUp(self) -> None:
229+
super().setUp()
230+
# DebugOutputFile aggressively tries to start just one output file. We
231+
# need to manually force it to make a new one.
232+
DebugOutputFile._del_singleton_data()
233+
234+
def debug_sys(self) -> None:
235+
"""Run just enough coverage to get full debug=sys output."""
236+
cov = coverage.Coverage(debug=["sys"])
237+
cov.start()
238+
cov.stop()
239+
240+
def test_stderr_default(self) -> None:
241+
self.debug_sys()
242+
assert_good_debug_sys(self.stderr())
243+
244+
def test_envvar(self) -> None:
245+
self.set_environ("COVERAGE_DEBUG_FILE", "debug.out")
246+
self.debug_sys()
247+
assert self.stderr() == ""
248+
with open("debug.out") as f:
249+
assert_good_debug_sys(f.read())
250+
251+
def test_config_file(self) -> None:
252+
self.make_file(".coveragerc", "[run]\ndebug_file = lotsa_info.txt")
253+
self.debug_sys()
254+
assert self.stderr() == ""
255+
with open("lotsa_info.txt") as f:
256+
assert_good_debug_sys(f.read())
257+
258+
219259
def f_one(*args: Any, **kwargs: Any) -> str:
220260
"""First of the chain of functions for testing `short_stack`."""
221261
return f_two(*args, **kwargs)

0 commit comments

Comments
 (0)