Skip to content

Commit c9d821d

Browse files
committed
feat: multiple --concurrency values. #1012 #1082
1 parent 97fdd55 commit c9d821d

14 files changed

+195
-81
lines changed

CHANGES.rst

+6
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ This list is detailed and covers changes in each pre-release version.
2222
Unreleased
2323
----------
2424

25+
- Feature: Now the ``--concurrency`` setting can have a list of values, so that
26+
threads and another lightweight threading package can be measured together.
27+
Closes `issue 1012`_ and `issue 1082`_.
28+
2529
- Fix: A module specified as the ``source`` setting is imported during startup,
2630
before the user program imports it. This could cause problems if the rest of
2731
the program isn't ready yet. For example, `issue 1203`_ describes a Django
@@ -49,6 +53,8 @@ Unreleased
4953
works, to allow for command-line options in the future.
5054

5155
.. _issue 989: https://github.com/nedbat/coveragepy/issues/989
56+
.. _issue 1012: https://github.com/nedbat/coveragepy/issues/1012
57+
.. _issue 1082: https://github.com/nedbat/coveragepy/issues/1802
5258
.. _issue 1203: https://github.com/nedbat/coveragepy/issues/1203
5359

5460

coverage/cmdline.py

+9-7
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from coverage import Coverage
1818
from coverage import env
1919
from coverage.collector import CTracer
20+
from coverage.config import CoverageConfig
2021
from coverage.data import combinable_files, debug_data_file
2122
from coverage.debug import info_formatter, info_header, short_stack
2223
from coverage.exceptions import _BaseCoverageException, _ExceptionDuringRun, NoSource
@@ -39,16 +40,12 @@ class Opts:
3940
'', '--branch', action='store_true',
4041
help="Measure branch coverage in addition to statement coverage.",
4142
)
42-
CONCURRENCY_CHOICES = [
43-
"thread", "gevent", "greenlet", "eventlet", "multiprocessing",
44-
]
4543
concurrency = optparse.make_option(
46-
'', '--concurrency', action='store', metavar="LIB",
47-
choices=CONCURRENCY_CHOICES,
44+
'', '--concurrency', action='store', metavar="LIBS",
4845
help=(
4946
"Properly measure code using a concurrency library. " +
5047
"Valid values are: {}."
51-
).format(", ".join(CONCURRENCY_CHOICES)),
48+
).format(", ".join(sorted(CoverageConfig.CONCURRENCY_CHOICES))),
5249
)
5350
context = optparse.make_option(
5451
'', '--context', action='store', metavar="LABEL",
@@ -570,6 +567,11 @@ def command_line(self, argv):
570567
debug = unshell_list(options.debug)
571568
contexts = unshell_list(options.contexts)
572569

570+
if options.concurrency is not None:
571+
concurrency = options.concurrency.split(",")
572+
else:
573+
concurrency = None
574+
573575
# Do something.
574576
self.coverage = Coverage(
575577
data_suffix=options.parallel_mode,
@@ -581,7 +583,7 @@ def command_line(self, argv):
581583
omit=omit,
582584
include=include,
583585
debug=debug,
584-
concurrency=options.concurrency,
586+
concurrency=concurrency,
585587
check_preimported=True,
586588
context=options.context,
587589
messages=not options.quiet,

coverage/collector.py

+56-45
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import sys
88

99
from coverage import env
10+
from coverage.config import CoverageConfig
1011
from coverage.debug import short_stack
1112
from coverage.disposition import FileDisposition
1213
from coverage.exceptions import ConfigError
@@ -55,7 +56,7 @@ class Collector:
5556
_collectors = []
5657

5758
# The concurrency settings we support here.
58-
SUPPORTED_CONCURRENCIES = {"greenlet", "eventlet", "gevent", "thread"}
59+
LIGHT_THREADS = {"greenlet", "eventlet", "gevent"}
5960

6061
def __init__(
6162
self, should_trace, check_include, should_start_context, file_mapper,
@@ -93,59 +94,28 @@ def __init__(
9394
9495
`concurrency` is a list of strings indicating the concurrency libraries
9596
in use. Valid values are "greenlet", "eventlet", "gevent", or "thread"
96-
(the default). Of these four values, only one can be supplied. Other
97-
values are ignored.
97+
(the default). "thread" can be combined with one of the other three.
98+
Other values are ignored.
9899
99100
"""
100101
self.should_trace = should_trace
101102
self.check_include = check_include
102103
self.should_start_context = should_start_context
103104
self.file_mapper = file_mapper
104-
self.warn = warn
105105
self.branch = branch
106+
self.warn = warn
107+
self.concurrency = concurrency
108+
assert isinstance(self.concurrency, list), f"Expected a list: {self.concurrency!r}"
109+
106110
self.threading = None
107111
self.covdata = None
108-
109112
self.static_context = None
110113

111114
self.origin = short_stack()
112115

113116
self.concur_id_func = None
114117
self.mapped_file_cache = {}
115118

116-
# We can handle a few concurrency options here, but only one at a time.
117-
these_concurrencies = self.SUPPORTED_CONCURRENCIES.intersection(concurrency)
118-
if len(these_concurrencies) > 1:
119-
raise ConfigError(f"Conflicting concurrency settings: {concurrency}")
120-
self.concurrency = these_concurrencies.pop() if these_concurrencies else ''
121-
122-
try:
123-
if self.concurrency == "greenlet":
124-
import greenlet
125-
self.concur_id_func = greenlet.getcurrent
126-
elif self.concurrency == "eventlet":
127-
import eventlet.greenthread # pylint: disable=import-error,useless-suppression
128-
self.concur_id_func = eventlet.greenthread.getcurrent
129-
elif self.concurrency == "gevent":
130-
import gevent # pylint: disable=import-error,useless-suppression
131-
self.concur_id_func = gevent.getcurrent
132-
elif self.concurrency == "thread" or not self.concurrency:
133-
# It's important to import threading only if we need it. If
134-
# it's imported early, and the program being measured uses
135-
# gevent, then gevent's monkey-patching won't work properly.
136-
import threading
137-
self.threading = threading
138-
else:
139-
raise ConfigError(f"Don't understand concurrency={concurrency}")
140-
except ImportError as ex:
141-
raise ConfigError(
142-
"Couldn't trace with concurrency={}, the module isn't installed.".format(
143-
self.concurrency,
144-
)
145-
) from ex
146-
147-
self.reset()
148-
149119
if timid:
150120
# Being timid: use the simple Python trace function.
151121
self._trace_class = PyTracer
@@ -163,6 +133,54 @@ def __init__(
163133
self.supports_plugins = False
164134
self.packed_arcs = False
165135

136+
# We can handle a few concurrency options here, but only one at a time.
137+
concurrencies = set(self.concurrency)
138+
unknown = concurrencies - CoverageConfig.CONCURRENCY_CHOICES
139+
if unknown:
140+
show = ", ".join(sorted(unknown))
141+
raise ConfigError(f"Unknown concurrency choices: {show}")
142+
light_threads = concurrencies & self.LIGHT_THREADS
143+
if len(light_threads) > 1:
144+
show = ", ".join(sorted(light_threads))
145+
raise ConfigError(f"Conflicting concurrency settings: {show}")
146+
do_threading = False
147+
148+
try:
149+
if "greenlet" in concurrencies:
150+
tried = "greenlet"
151+
import greenlet
152+
self.concur_id_func = greenlet.getcurrent
153+
elif "eventlet" in concurrencies:
154+
tried = "eventlet"
155+
import eventlet.greenthread # pylint: disable=import-error,useless-suppression
156+
self.concur_id_func = eventlet.greenthread.getcurrent
157+
elif "gevent" in concurrencies:
158+
tried = "gevent"
159+
import gevent # pylint: disable=import-error,useless-suppression
160+
self.concur_id_func = gevent.getcurrent
161+
162+
if "thread" in concurrencies:
163+
do_threading = True
164+
except ImportError as ex:
165+
msg = f"Couldn't trace with concurrency={tried}, the module isn't installed."
166+
raise ConfigError(msg) from ex
167+
168+
if self.concur_id_func and not hasattr(self._trace_class, "concur_id_func"):
169+
raise ConfigError(
170+
"Can't support concurrency={} with {}, only threads are supported.".format(
171+
tried, self.tracer_name(),
172+
)
173+
)
174+
175+
if do_threading or not concurrencies:
176+
# It's important to import threading only if we need it. If
177+
# it's imported early, and the program being measured uses
178+
# gevent, then gevent's monkey-patching won't work properly.
179+
import threading
180+
self.threading = threading
181+
182+
self.reset()
183+
166184
def __repr__(self):
167185
return f"<Collector at 0x{id(self):x}: {self.tracer_name()}>"
168186

@@ -244,13 +262,6 @@ def _start_tracer(self):
244262

245263
if hasattr(tracer, 'concur_id_func'):
246264
tracer.concur_id_func = self.concur_id_func
247-
elif self.concur_id_func:
248-
raise ConfigError(
249-
"Can't support concurrency={} with {}, only threads are supported".format(
250-
self.concurrency, self.tracer_name(),
251-
)
252-
)
253-
254265
if hasattr(tracer, 'file_tracers'):
255266
tracer.file_tracers = self.file_tracers
256267
if hasattr(tracer, 'threading'):

coverage/config.py

+2
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,8 @@ def copy(self):
334334
"""Return a copy of the configuration."""
335335
return copy.deepcopy(self)
336336

337+
CONCURRENCY_CHOICES = {"thread", "gevent", "greenlet", "eventlet", "multiprocessing"}
338+
337339
CONFIG_FILE_OPTIONS = [
338340
# These are *args for _set_attr_from_config_option:
339341
# (attr, where, type_="")

coverage/control.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -448,7 +448,7 @@ def load(self):
448448
def _init_for_start(self):
449449
"""Initialization for start()"""
450450
# Construct the collector.
451-
concurrency = self.config.concurrency or ()
451+
concurrency = self.config.concurrency or []
452452
if "multiprocessing" in concurrency:
453453
if not patch_multiprocessing:
454454
raise ConfigError( # pragma: only jython

coverage/version.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
# This file is exec'ed in setup.py, don't import anything!
66

77
# Same semantics as sys.version_info.
8-
version_info = (6, 1, 3, "alpha", 0)
8+
version_info = (6, 2, 0, "alpha", 0)
99

1010

1111
def _make_version(major, minor, micro, releaselevel, serial):

doc/cmd.rst

+10-6
Original file line numberDiff line numberDiff line change
@@ -124,9 +124,9 @@ There are many options:
124124
clean each time.
125125
--branch Measure branch coverage in addition to statement
126126
coverage.
127-
--concurrency=LIB Properly measure code using a concurrency library.
128-
Valid values are: thread, gevent, greenlet, eventlet,
129-
multiprocessing.
127+
--concurrency=LIBS Properly measure code using a concurrency library.
128+
Valid values are: eventlet, gevent, greenlet,
129+
multiprocessing, thread.
130130
--context=LABEL The context label to record for this coverage run.
131131
--include=PAT1,PAT2,...
132132
Include only files whose paths match one of these
@@ -152,7 +152,7 @@ There are many options:
152152
--rcfile=RCFILE Specify configuration file. By default '.coveragerc',
153153
'setup.cfg', 'tox.ini', and 'pyproject.toml' are
154154
tried. [env: COVERAGE_RCFILE]
155-
.. [[[end]]] (checksum: 869a31153b3cf401c52523ae9b52c7ab)
155+
.. [[[end]]] (checksum: 072cccad7f8ad3e7b72c266305ef5e4a)
156156
157157
If you want :ref:`branch coverage <branch>` measurement, use the ``--branch``
158158
flag. Otherwise only statement coverage is measured.
@@ -174,13 +174,17 @@ but before the program invocation::
174174

175175

176176
Coverage.py can measure multi-threaded programs by default. If you are using
177-
more exotic concurrency, with the `multiprocessing`_, `greenlet`_, `eventlet`_,
178-
or `gevent`_ libraries, then coverage.py will get very confused. Use the
177+
more other concurrency support, with the `multiprocessing`_, `greenlet`_,
178+
`eventlet`_, or `gevent`_ libraries, then coverage.py can get confused. Use the
179179
``--concurrency`` switch to properly measure programs using these libraries.
180180
Give it a value of ``multiprocessing``, ``thread``, ``greenlet``, ``eventlet``,
181181
or ``gevent``. Values other than ``thread`` require the :ref:`C extension
182182
<install_extension>`.
183183

184+
You can combine multiple values for ``--concurrency``, separated with commas.
185+
You can specify ``thread`` and also one of ``eventlet``, ``gevent``, or
186+
``greenlet``.
187+
184188
If you are using ``--concurrency=multiprocessing``, you must set other options
185189
in the configuration file. Options on the command line will not be passed to
186190
the processes that multiprocessing creates. Best practice is to use the

doc/requirements.pip

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ babel==2.9.1
1010
# via sphinx
1111
certifi==2021.10.8
1212
# via requests
13-
charset-normalizer==2.0.7
13+
charset-normalizer==2.0.8
1414
# via requests
1515
cogapp==3.3.0
1616
# via -r doc/requirements.in

requirements/dev.pip

+1-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ build==0.7.0
2525
# via check-manifest
2626
certifi==2021.10.8
2727
# via requests
28-
charset-normalizer==2.0.7
28+
charset-normalizer==2.0.8
2929
# via requests
3030
check-manifest==0.47
3131
# via -r requirements/dev.in

requirements/light-threads.in

+1
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@
66
# The light-threads packages we test against
77

88
eventlet
9+
gevent
910
greenlet

requirements/light-threads.pip

+11
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,20 @@ dnspython==2.1.0
88
# via eventlet
99
eventlet==0.33.0
1010
# via -r requirements/light-threads.in
11+
gevent==21.8.0
12+
# via -r requirements/light-threads.in
1113
greenlet==1.1.2
1214
# via
1315
# -r requirements/light-threads.in
1416
# eventlet
17+
# gevent
1518
six==1.16.0
1619
# via eventlet
20+
zope.event==4.5.0
21+
# via gevent
22+
zope.interface==5.4.0
23+
# via gevent
24+
25+
# The following packages are considered to be unsafe in a requirements file:
26+
setuptools==59.2.0
27+
# via gevent

tests/test_cmdline.py

+11-15
Original file line numberDiff line numberDiff line change
@@ -610,7 +610,7 @@ def test_run(self):
610610
cov.save()
611611
""")
612612
self.cmd_executes("run --concurrency=gevent foo.py", """\
613-
cov = Coverage(concurrency='gevent')
613+
cov = Coverage(concurrency=['gevent'])
614614
runner = PyRunner(['foo.py'], as_module=False)
615615
runner.prepare()
616616
cov.start()
@@ -619,27 +619,23 @@ def test_run(self):
619619
cov.save()
620620
""")
621621
self.cmd_executes("run --concurrency=multiprocessing foo.py", """\
622-
cov = Coverage(concurrency='multiprocessing')
622+
cov = Coverage(concurrency=['multiprocessing'])
623+
runner = PyRunner(['foo.py'], as_module=False)
624+
runner.prepare()
625+
cov.start()
626+
runner.run()
627+
cov.stop()
628+
cov.save()
629+
""")
630+
self.cmd_executes("run --concurrency=gevent,thread foo.py", """\
631+
cov = Coverage(concurrency=['gevent', 'thread'])
623632
runner = PyRunner(['foo.py'], as_module=False)
624633
runner.prepare()
625634
cov.start()
626635
runner.run()
627636
cov.stop()
628637
cov.save()
629638
""")
630-
631-
def test_bad_concurrency(self):
632-
self.command_line("run --concurrency=nothing", ret=ERR)
633-
err = self.stderr()
634-
assert "option --concurrency: invalid choice: 'nothing'" in err
635-
636-
def test_no_multiple_concurrency(self):
637-
# You can't use multiple concurrency values on the command line.
638-
# I would like to have a better message about not allowing multiple
639-
# values for this option, but optparse is not that flexible.
640-
self.command_line("run --concurrency=multiprocessing,gevent foo.py", ret=ERR)
641-
err = self.stderr()
642-
assert "option --concurrency: invalid choice: 'multiprocessing,gevent'" in err
643639

644640
def test_multiprocessing_needs_config_file(self):
645641
# You can't use command-line args to add options to multiprocessing

0 commit comments

Comments
 (0)