diff options
-rw-r--r-- | CHANGES.rst | 6 | ||||
-rw-r--r-- | coverage/cmdline.py | 16 | ||||
-rw-r--r-- | coverage/collector.py | 101 | ||||
-rw-r--r-- | coverage/config.py | 2 | ||||
-rw-r--r-- | coverage/control.py | 2 | ||||
-rw-r--r-- | coverage/version.py | 2 | ||||
-rw-r--r-- | doc/cmd.rst | 16 | ||||
-rw-r--r-- | doc/requirements.pip | 2 | ||||
-rw-r--r-- | requirements/dev.pip | 2 | ||||
-rw-r--r-- | requirements/light-threads.in | 1 | ||||
-rw-r--r-- | requirements/light-threads.pip | 11 | ||||
-rw-r--r-- | tests/test_cmdline.py | 26 | ||||
-rw-r--r-- | tests/test_concurrency.py | 87 | ||||
-rw-r--r-- | tox.ini | 2 |
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 @@ -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 |