summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorNed Batchelder <ned@nedbatchelder.com>2021-11-23 06:35:45 -0500
committerNed Batchelder <ned@nedbatchelder.com>2021-11-25 15:03:08 -0500
commitc9d821deba6f7ee5eef30fef5355f7c93808b4f9 (patch)
treefb2d4d88de781e203d8beae8260e17380c5553f9
parent97fdd550020384d2eedaf72ff0cd46a4efcb7d05 (diff)
downloadpython-coveragepy-git-nedbat/multi-concurrency.tar.gz
feat: multiple --concurrency values. #1012 #1082nedbat/multi-concurrency
-rw-r--r--CHANGES.rst6
-rw-r--r--coverage/cmdline.py16
-rw-r--r--coverage/collector.py101
-rw-r--r--coverage/config.py2
-rw-r--r--coverage/control.py2
-rw-r--r--coverage/version.py2
-rw-r--r--doc/cmd.rst16
-rw-r--r--doc/requirements.pip2
-rw-r--r--requirements/dev.pip2
-rw-r--r--requirements/light-threads.in1
-rw-r--r--requirements/light-threads.pip11
-rw-r--r--tests/test_cmdline.py26
-rw-r--r--tests/test_concurrency.py87
-rw-r--r--tox.ini2
14 files changed, 195 insertions, 81 deletions
diff --git a/CHANGES.rst b/CHANGES.rst
index b3da02f6..e77d16a7 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -22,6 +22,10 @@ This list is detailed and covers changes in each pre-release version.
Unreleased
----------
+- Feature: Now the ``--concurrency`` setting can have a list of values, so that
+ threads and another lightweight threading package can be measured together.
+ Closes `issue 1012`_ and `issue 1082`_.
+
- Fix: A module specified as the ``source`` setting is imported during startup,
before the user program imports it. This could cause problems if the rest of
the program isn't ready yet. For example, `issue 1203`_ describes a Django
@@ -49,6 +53,8 @@ Unreleased
works, to allow for command-line options in the future.
.. _issue 989: https://github.com/nedbat/coveragepy/issues/989
+.. _issue 1012: https://github.com/nedbat/coveragepy/issues/1012
+.. _issue 1082: https://github.com/nedbat/coveragepy/issues/1802
.. _issue 1203: https://github.com/nedbat/coveragepy/issues/1203
diff --git a/coverage/cmdline.py b/coverage/cmdline.py
index ae20acc5..ec809330 100644
--- a/coverage/cmdline.py
+++ b/coverage/cmdline.py
@@ -17,6 +17,7 @@ import coverage
from coverage import Coverage
from coverage import env
from coverage.collector import CTracer
+from coverage.config import CoverageConfig
from coverage.data import combinable_files, debug_data_file
from coverage.debug import info_formatter, info_header, short_stack
from coverage.exceptions import _BaseCoverageException, _ExceptionDuringRun, NoSource
@@ -39,16 +40,12 @@ class Opts:
'', '--branch', action='store_true',
help="Measure branch coverage in addition to statement coverage.",
)
- CONCURRENCY_CHOICES = [
- "thread", "gevent", "greenlet", "eventlet", "multiprocessing",
- ]
concurrency = optparse.make_option(
- '', '--concurrency', action='store', metavar="LIB",
- choices=CONCURRENCY_CHOICES,
+ '', '--concurrency', action='store', metavar="LIBS",
help=(
"Properly measure code using a concurrency library. " +
"Valid values are: {}."
- ).format(", ".join(CONCURRENCY_CHOICES)),
+ ).format(", ".join(sorted(CoverageConfig.CONCURRENCY_CHOICES))),
)
context = optparse.make_option(
'', '--context', action='store', metavar="LABEL",
@@ -570,6 +567,11 @@ class CoverageScript:
debug = unshell_list(options.debug)
contexts = unshell_list(options.contexts)
+ if options.concurrency is not None:
+ concurrency = options.concurrency.split(",")
+ else:
+ concurrency = None
+
# Do something.
self.coverage = Coverage(
data_suffix=options.parallel_mode,
@@ -581,7 +583,7 @@ class CoverageScript:
omit=omit,
include=include,
debug=debug,
- concurrency=options.concurrency,
+ concurrency=concurrency,
check_preimported=True,
context=options.context,
messages=not options.quiet,
diff --git a/coverage/collector.py b/coverage/collector.py
index 89ba66ba..0397031a 100644
--- a/coverage/collector.py
+++ b/coverage/collector.py
@@ -7,6 +7,7 @@ import os
import sys
from coverage import env
+from coverage.config import CoverageConfig
from coverage.debug import short_stack
from coverage.disposition import FileDisposition
from coverage.exceptions import ConfigError
@@ -55,7 +56,7 @@ class Collector:
_collectors = []
# The concurrency settings we support here.
- SUPPORTED_CONCURRENCIES = {"greenlet", "eventlet", "gevent", "thread"}
+ LIGHT_THREADS = {"greenlet", "eventlet", "gevent"}
def __init__(
self, should_trace, check_include, should_start_context, file_mapper,
@@ -93,19 +94,21 @@ class Collector:
`concurrency` is a list of strings indicating the concurrency libraries
in use. Valid values are "greenlet", "eventlet", "gevent", or "thread"
- (the default). Of these four values, only one can be supplied. Other
- values are ignored.
+ (the default). "thread" can be combined with one of the other three.
+ Other values are ignored.
"""
self.should_trace = should_trace
self.check_include = check_include
self.should_start_context = should_start_context
self.file_mapper = file_mapper
- self.warn = warn
self.branch = branch
+ self.warn = warn
+ self.concurrency = concurrency
+ assert isinstance(self.concurrency, list), f"Expected a list: {self.concurrency!r}"
+
self.threading = None
self.covdata = None
-
self.static_context = None
self.origin = short_stack()
@@ -113,39 +116,6 @@ class Collector:
self.concur_id_func = None
self.mapped_file_cache = {}
- # We can handle a few concurrency options here, but only one at a time.
- these_concurrencies = self.SUPPORTED_CONCURRENCIES.intersection(concurrency)
- if len(these_concurrencies) > 1:
- raise ConfigError(f"Conflicting concurrency settings: {concurrency}")
- self.concurrency = these_concurrencies.pop() if these_concurrencies else ''
-
- try:
- if self.concurrency == "greenlet":
- import greenlet
- self.concur_id_func = greenlet.getcurrent
- elif self.concurrency == "eventlet":
- import eventlet.greenthread # pylint: disable=import-error,useless-suppression
- self.concur_id_func = eventlet.greenthread.getcurrent
- elif self.concurrency == "gevent":
- import gevent # pylint: disable=import-error,useless-suppression
- self.concur_id_func = gevent.getcurrent
- elif self.concurrency == "thread" or not self.concurrency:
- # It's important to import threading only if we need it. If
- # it's imported early, and the program being measured uses
- # gevent, then gevent's monkey-patching won't work properly.
- import threading
- self.threading = threading
- else:
- raise ConfigError(f"Don't understand concurrency={concurrency}")
- except ImportError as ex:
- raise ConfigError(
- "Couldn't trace with concurrency={}, the module isn't installed.".format(
- self.concurrency,
- )
- ) from ex
-
- self.reset()
-
if timid:
# Being timid: use the simple Python trace function.
self._trace_class = PyTracer
@@ -163,6 +133,54 @@ class Collector:
self.supports_plugins = False
self.packed_arcs = False
+ # We can handle a few concurrency options here, but only one at a time.
+ concurrencies = set(self.concurrency)
+ unknown = concurrencies - CoverageConfig.CONCURRENCY_CHOICES
+ if unknown:
+ show = ", ".join(sorted(unknown))
+ raise ConfigError(f"Unknown concurrency choices: {show}")
+ light_threads = concurrencies & self.LIGHT_THREADS
+ if len(light_threads) > 1:
+ show = ", ".join(sorted(light_threads))
+ raise ConfigError(f"Conflicting concurrency settings: {show}")
+ do_threading = False
+
+ try:
+ if "greenlet" in concurrencies:
+ tried = "greenlet"
+ import greenlet
+ self.concur_id_func = greenlet.getcurrent
+ elif "eventlet" in concurrencies:
+ tried = "eventlet"
+ import eventlet.greenthread # pylint: disable=import-error,useless-suppression
+ self.concur_id_func = eventlet.greenthread.getcurrent
+ elif "gevent" in concurrencies:
+ tried = "gevent"
+ import gevent # pylint: disable=import-error,useless-suppression
+ self.concur_id_func = gevent.getcurrent
+
+ if "thread" in concurrencies:
+ do_threading = True
+ except ImportError as ex:
+ msg = f"Couldn't trace with concurrency={tried}, the module isn't installed."
+ raise ConfigError(msg) from ex
+
+ if self.concur_id_func and not hasattr(self._trace_class, "concur_id_func"):
+ raise ConfigError(
+ "Can't support concurrency={} with {}, only threads are supported.".format(
+ tried, self.tracer_name(),
+ )
+ )
+
+ if do_threading or not concurrencies:
+ # It's important to import threading only if we need it. If
+ # it's imported early, and the program being measured uses
+ # gevent, then gevent's monkey-patching won't work properly.
+ import threading
+ self.threading = threading
+
+ self.reset()
+
def __repr__(self):
return f"<Collector at 0x{id(self):x}: {self.tracer_name()}>"
@@ -244,13 +262,6 @@ class Collector:
if hasattr(tracer, 'concur_id_func'):
tracer.concur_id_func = self.concur_id_func
- elif self.concur_id_func:
- raise ConfigError(
- "Can't support concurrency={} with {}, only threads are supported".format(
- self.concurrency, self.tracer_name(),
- )
- )
-
if hasattr(tracer, 'file_tracers'):
tracer.file_tracers = self.file_tracers
if hasattr(tracer, 'threading'):
diff --git a/coverage/config.py b/coverage/config.py
index 8ed2dee7..9835e341 100644
--- a/coverage/config.py
+++ b/coverage/config.py
@@ -334,6 +334,8 @@ class CoverageConfig:
"""Return a copy of the configuration."""
return copy.deepcopy(self)
+ CONCURRENCY_CHOICES = {"thread", "gevent", "greenlet", "eventlet", "multiprocessing"}
+
CONFIG_FILE_OPTIONS = [
# These are *args for _set_attr_from_config_option:
# (attr, where, type_="")
diff --git a/coverage/control.py b/coverage/control.py
index 00836b3c..99319c05 100644
--- a/coverage/control.py
+++ b/coverage/control.py
@@ -448,7 +448,7 @@ class Coverage:
def _init_for_start(self):
"""Initialization for start()"""
# Construct the collector.
- concurrency = self.config.concurrency or ()
+ concurrency = self.config.concurrency or []
if "multiprocessing" in concurrency:
if not patch_multiprocessing:
raise ConfigError( # pragma: only jython
diff --git a/coverage/version.py b/coverage/version.py
index 394cd076..110eceb7 100644
--- a/coverage/version.py
+++ b/coverage/version.py
@@ -5,7 +5,7 @@
# This file is exec'ed in setup.py, don't import anything!
# Same semantics as sys.version_info.
-version_info = (6, 1, 3, "alpha", 0)
+version_info = (6, 2, 0, "alpha", 0)
def _make_version(major, minor, micro, releaselevel, serial):
diff --git a/doc/cmd.rst b/doc/cmd.rst
index 8c37781f..3b1c51cf 100644
--- a/doc/cmd.rst
+++ b/doc/cmd.rst
@@ -124,9 +124,9 @@ There are many options:
clean each time.
--branch Measure branch coverage in addition to statement
coverage.
- --concurrency=LIB Properly measure code using a concurrency library.
- Valid values are: thread, gevent, greenlet, eventlet,
- multiprocessing.
+ --concurrency=LIBS Properly measure code using a concurrency library.
+ Valid values are: eventlet, gevent, greenlet,
+ multiprocessing, thread.
--context=LABEL The context label to record for this coverage run.
--include=PAT1,PAT2,...
Include only files whose paths match one of these
@@ -152,7 +152,7 @@ There are many options:
--rcfile=RCFILE Specify configuration file. By default '.coveragerc',
'setup.cfg', 'tox.ini', and 'pyproject.toml' are
tried. [env: COVERAGE_RCFILE]
-.. [[[end]]] (checksum: 869a31153b3cf401c52523ae9b52c7ab)
+.. [[[end]]] (checksum: 072cccad7f8ad3e7b72c266305ef5e4a)
If you want :ref:`branch coverage <branch>` measurement, use the ``--branch``
flag. Otherwise only statement coverage is measured.
@@ -174,13 +174,17 @@ but before the program invocation::
Coverage.py can measure multi-threaded programs by default. If you are using
-more exotic concurrency, with the `multiprocessing`_, `greenlet`_, `eventlet`_,
-or `gevent`_ libraries, then coverage.py will get very confused. Use the
+more other concurrency support, with the `multiprocessing`_, `greenlet`_,
+`eventlet`_, or `gevent`_ libraries, then coverage.py can get confused. Use the
``--concurrency`` switch to properly measure programs using these libraries.
Give it a value of ``multiprocessing``, ``thread``, ``greenlet``, ``eventlet``,
or ``gevent``. Values other than ``thread`` require the :ref:`C extension
<install_extension>`.
+You can combine multiple values for ``--concurrency``, separated with commas.
+You can specify ``thread`` and also one of ``eventlet``, ``gevent``, or
+``greenlet``.
+
If you are using ``--concurrency=multiprocessing``, you must set other options
in the configuration file. Options on the command line will not be passed to
the processes that multiprocessing creates. Best practice is to use the
diff --git a/doc/requirements.pip b/doc/requirements.pip
index 276f4a99..e6fe8b42 100644
--- a/doc/requirements.pip
+++ b/doc/requirements.pip
@@ -10,7 +10,7 @@ babel==2.9.1
# via sphinx
certifi==2021.10.8
# via requests
-charset-normalizer==2.0.7
+charset-normalizer==2.0.8
# via requests
cogapp==3.3.0
# via -r doc/requirements.in
diff --git a/requirements/dev.pip b/requirements/dev.pip
index f187ae49..fd34e61a 100644
--- a/requirements/dev.pip
+++ b/requirements/dev.pip
@@ -25,7 +25,7 @@ build==0.7.0
# via check-manifest
certifi==2021.10.8
# via requests
-charset-normalizer==2.0.7
+charset-normalizer==2.0.8
# via requests
check-manifest==0.47
# via -r requirements/dev.in
diff --git a/requirements/light-threads.in b/requirements/light-threads.in
index 73f75df5..7c80ba3d 100644
--- a/requirements/light-threads.in
+++ b/requirements/light-threads.in
@@ -6,4 +6,5 @@
# The light-threads packages we test against
eventlet
+gevent
greenlet
diff --git a/requirements/light-threads.pip b/requirements/light-threads.pip
index cce5545d..de8132c4 100644
--- a/requirements/light-threads.pip
+++ b/requirements/light-threads.pip
@@ -8,9 +8,20 @@ dnspython==2.1.0
# via eventlet
eventlet==0.33.0
# via -r requirements/light-threads.in
+gevent==21.8.0
+ # via -r requirements/light-threads.in
greenlet==1.1.2
# via
# -r requirements/light-threads.in
# eventlet
+ # gevent
six==1.16.0
# via eventlet
+zope.event==4.5.0
+ # via gevent
+zope.interface==5.4.0
+ # via gevent
+
+# The following packages are considered to be unsafe in a requirements file:
+setuptools==59.2.0
+ # via gevent
diff --git a/tests/test_cmdline.py b/tests/test_cmdline.py
index ab4b9e20..42f313f8 100644
--- a/tests/test_cmdline.py
+++ b/tests/test_cmdline.py
@@ -610,7 +610,7 @@ class CmdLineTest(BaseCmdLineTest):
cov.save()
""")
self.cmd_executes("run --concurrency=gevent foo.py", """\
- cov = Coverage(concurrency='gevent')
+ cov = Coverage(concurrency=['gevent'])
runner = PyRunner(['foo.py'], as_module=False)
runner.prepare()
cov.start()
@@ -619,7 +619,16 @@ class CmdLineTest(BaseCmdLineTest):
cov.save()
""")
self.cmd_executes("run --concurrency=multiprocessing foo.py", """\
- cov = Coverage(concurrency='multiprocessing')
+ cov = Coverage(concurrency=['multiprocessing'])
+ runner = PyRunner(['foo.py'], as_module=False)
+ runner.prepare()
+ cov.start()
+ runner.run()
+ cov.stop()
+ cov.save()
+ """)
+ self.cmd_executes("run --concurrency=gevent,thread foo.py", """\
+ cov = Coverage(concurrency=['gevent', 'thread'])
runner = PyRunner(['foo.py'], as_module=False)
runner.prepare()
cov.start()
@@ -627,19 +636,6 @@ class CmdLineTest(BaseCmdLineTest):
cov.stop()
cov.save()
""")
-
- def test_bad_concurrency(self):
- self.command_line("run --concurrency=nothing", ret=ERR)
- err = self.stderr()
- assert "option --concurrency: invalid choice: 'nothing'" in err
-
- def test_no_multiple_concurrency(self):
- # You can't use multiple concurrency values on the command line.
- # I would like to have a better message about not allowing multiple
- # values for this option, but optparse is not that flexible.
- self.command_line("run --concurrency=multiprocessing,gevent foo.py", ret=ERR)
- err = self.stderr()
- assert "option --concurrency: invalid choice: 'multiprocessing,gevent'" in err
def test_multiprocessing_needs_config_file(self):
# You can't use command-line args to add options to multiprocessing
diff --git a/tests/test_concurrency.py b/tests/test_concurrency.py
index c37c88be..69157968 100644
--- a/tests/test_concurrency.py
+++ b/tests/test_concurrency.py
@@ -17,6 +17,7 @@ import pytest
import coverage
from coverage import env
from coverage.data import line_counts
+from coverage.exceptions import ConfigError
from coverage.files import abs_file
from coverage.misc import import_local_file
@@ -193,7 +194,7 @@ def cant_trace_msg(concurrency, the_module):
expected_out = None
else:
expected_out = (
- f"Can't support concurrency={concurrency} with PyTracer, only threads are supported\n"
+ f"Can't support concurrency={concurrency} with PyTracer, only threads are supported.\n"
)
return expected_out
@@ -212,7 +213,6 @@ class ConcurrencyTest(CoverageTest):
is the text we expect the code to produce.
"""
-
self.make_file("try_it.py", code)
cmd = f"coverage run --concurrency={concurrency} try_it.py"
@@ -261,6 +261,8 @@ class ConcurrencyTest(CoverageTest):
code = SIMPLE.format(QLIMIT=self.QLIMIT)
self.try_some_code(code, "eventlet", eventlet)
+ # https://github.com/nedbat/coveragepy/issues/663
+ @pytest.mark.skipif(env.WINDOWS, reason="gevent has problems on Windows: #663")
def test_gevent(self):
code = (GEVENT + SUM_RANGE_Q + PRINT_SUM_RANGE).format(QLIMIT=self.QLIMIT)
self.try_some_code(code, "gevent", gevent)
@@ -309,6 +311,85 @@ class ConcurrencyTest(CoverageTest):
"""
self.try_some_code(BUG_330, "eventlet", eventlet, "0\n")
+ def test_threads_with_gevent(self):
+ self.make_file("both.py", """\
+ import queue
+ import threading
+
+ import gevent
+
+ def work1(q):
+ q.put(1)
+
+ def gwork(q):
+ gevent.spawn(work1, q).join()
+ q.put(None)
+ print("done")
+
+ q = queue.Queue()
+ t = threading.Thread(target=gwork, args=(q,))
+ t.start()
+ t.join()
+
+ answer = q.get()
+ assert answer == 1
+ """)
+ out = self.run_command("coverage run --concurrency=thread,gevent both.py")
+ if gevent is None:
+ assert out == (
+ "Couldn't trace with concurrency=gevent, the module isn't installed.\n"
+ )
+ pytest.skip("Can't run test without gevent installed.")
+ if not env.C_TRACER:
+ assert out == (
+ "Can't support concurrency=gevent with PyTracer, only threads are supported.\n"
+ )
+ pytest.skip("Can't run gevent with PyTracer")
+
+ assert out == "done\n"
+
+ out = self.run_command("coverage report -m")
+ last_line = self.squeezed_lines(out)[-1]
+ assert re.search(r"TOTAL \d+ 0 100%", last_line)
+
+ def test_bad_concurrency(self):
+ self.make_file("prog.py", "a = 1")
+ msg = "Unknown concurrency choices: nothing"
+ with pytest.raises(ConfigError, match=msg):
+ self.command_line("run --concurrency=nothing prog.py")
+
+ def test_bad_concurrency_in_config(self):
+ self.make_file("prog.py", "a = 1")
+ self.make_file(".coveragerc", "[run]\nconcurrency = nothing\n")
+ msg = "Unknown concurrency choices: nothing"
+ with pytest.raises(ConfigError, match=msg):
+ self.command_line("run prog.py")
+
+ def test_no_multiple_light_concurrency(self):
+ self.make_file("prog.py", "a = 1")
+ msg = "Conflicting concurrency settings: eventlet, gevent"
+ with pytest.raises(ConfigError, match=msg):
+ self.command_line("run --concurrency=gevent,eventlet prog.py")
+
+ def test_no_multiple_light_concurrency_in_config(self):
+ self.make_file("prog.py", "a = 1")
+ self.make_file(".coveragerc", "[run]\nconcurrency = gevent, eventlet\n")
+ msg = "Conflicting concurrency settings: eventlet, gevent"
+ with pytest.raises(ConfigError, match=msg):
+ self.command_line("run prog.py")
+
+
+class WithoutConcurrencyModuleTest(CoverageTest):
+ """Tests of what happens if the requested concurrency isn't installed."""
+
+ @pytest.mark.parametrize("module", ["eventlet", "gevent", "greenlet"])
+ def test_missing_module(self, module):
+ self.make_file("prog.py", "a = 1")
+ sys.modules[module] = None
+ msg = f"Couldn't trace with concurrency={module}, the module isn't installed."
+ with pytest.raises(ConfigError, match=msg):
+ self.command_line(f"run --concurrency={module} prog.py")
+
SQUARE_OR_CUBE_WORK = """
def work(x):
@@ -385,7 +466,9 @@ class MultiprocessingTest(CoverageTest):
expected_cant_trace = cant_trace_msg(concurrency, the_module)
if expected_cant_trace is not None:
+ print(out)
assert out == expected_cant_trace
+ pytest.skip(f"Can't test: {expected_cant_trace}")
else:
assert out.rstrip() == expected_out
assert len(glob.glob(".coverage.*")) == nprocs + 1
diff --git a/tox.ini b/tox.ini
index 6d756c0c..2a8d1bb8 100644
--- a/tox.ini
+++ b/tox.ini
@@ -15,8 +15,6 @@ extras =
deps =
-r requirements/pip.pip
-r requirements/pytest.pip
- # gevent 1.3 causes a failure: https://github.com/nedbat/coveragepy/issues/663
- py{36}: gevent==1.2.2
py{36,37,38,39,310}: -r requirements/light-threads.pip
# Windows can't update the pip version with pip running, so use Python