Skip to content

Commit

Permalink
feat: multiple --concurrency values. #1012 #1082
Browse files Browse the repository at this point in the history
  • Loading branch information
nedbat committed Nov 25, 2021
1 parent 97fdd55 commit 1f346fa
Show file tree
Hide file tree
Showing 15 changed files with 182 additions and 82 deletions.
6 changes: 6 additions & 0 deletions CHANGES.rst
Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand Down
2 changes: 1 addition & 1 deletion Makefile
Expand Up @@ -62,7 +62,7 @@ metahtml: ## Produce meta-coverage HTML reports.
python igor.py combine_html

metasmoke:
COVERAGE_NO_PYTRACER=1 ARGS="-e py39" make clean metacov metahtml
COVERAGE_NO_PYTRACER=1 ARGS="-e py36" make clean metacov metahtml

PIP_COMPILE = pip-compile --upgrade --allow-unsafe
upgrade: export CUSTOM_COMPILE_COMMAND=make upgrade
Expand Down
16 changes: 9 additions & 7 deletions coverage/cmdline.py
Expand Up @@ -17,6 +17,7 @@
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
Expand All @@ -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",
Expand Down Expand Up @@ -570,6 +567,11 @@ def command_line(self, argv):
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,
Expand All @@ -581,7 +583,7 @@ def command_line(self, argv):
omit=omit,
include=include,
debug=debug,
concurrency=options.concurrency,
concurrency=concurrency,
check_preimported=True,
context=options.context,
messages=not options.quiet,
Expand Down
101 changes: 56 additions & 45 deletions coverage/collector.py
Expand Up @@ -7,6 +7,7 @@
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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -93,59 +94,28 @@ def __init__(
`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()

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
Expand All @@ -163,6 +133,54 @@ def __init__(
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()}>"

Expand Down Expand Up @@ -244,13 +262,6 @@ def _start_tracer(self):

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'):
Expand Down
2 changes: 2 additions & 0 deletions coverage/config.py
Expand Up @@ -334,6 +334,8 @@ def copy(self):
"""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_="")
Expand Down
2 changes: 1 addition & 1 deletion coverage/control.py
Expand Up @@ -448,7 +448,7 @@ def load(self):
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
Expand Down
2 changes: 1 addition & 1 deletion coverage/version.py
Expand Up @@ -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):
Expand Down
16 changes: 10 additions & 6 deletions doc/cmd.rst
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion doc/requirements.pip
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion requirements/dev.pip
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions requirements/light-threads.in
Expand Up @@ -6,4 +6,5 @@
# The light-threads packages we test against

eventlet
gevent
greenlet
11 changes: 11 additions & 0 deletions requirements/light-threads.pip
Expand Up @@ -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
26 changes: 11 additions & 15 deletions tests/test_cmdline.py
Expand Up @@ -610,7 +610,7 @@ def test_run(self):
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()
Expand All @@ -619,27 +619,23 @@ def test_run(self):
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()
runner.run()
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
Expand Down

0 comments on commit 1f346fa

Please sign in to comment.