diff --git a/CHANGES.rst b/CHANGES.rst index b3da02f62..e77d16a75 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/Makefile b/Makefile index 3609b11c8..d349426ba 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/coverage/cmdline.py b/coverage/cmdline.py index ae20acc5a..ec8093307 100644 --- a/coverage/cmdline.py +++ b/coverage/cmdline.py @@ -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 @@ -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 @@ 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, @@ -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, diff --git a/coverage/collector.py b/coverage/collector.py index 89ba66ba1..0397031ae 100644 --- a/coverage/collector.py +++ b/coverage/collector.py @@ -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 @@ -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 @@ 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() @@ -113,39 +116,6 @@ def __init__( 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 @@ 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"" @@ -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'): diff --git a/coverage/config.py b/coverage/config.py index 8ed2dee7d..9835e3417 100644 --- a/coverage/config.py +++ b/coverage/config.py @@ -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_="") diff --git a/coverage/control.py b/coverage/control.py index 00836b3cc..99319c056 100644 --- a/coverage/control.py +++ b/coverage/control.py @@ -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 diff --git a/coverage/version.py b/coverage/version.py index 394cd0761..110eceb7c 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 8c37781f7..3b1c51cf8 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 ` 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 `. +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 276f4a99e..e6fe8b428 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 f187ae493..fd34e61a4 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 73f75df5d..7c80ba3dc 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 cce5545d4..de8132c4f 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 ab4b9e20c..42f313f81 100644 --- a/tests/test_cmdline.py +++ b/tests/test_cmdline.py @@ -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() @@ -619,7 +619,16 @@ 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() @@ -627,19 +636,6 @@ def test_run(self): 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 c37c88beb..c31c84811 100644 --- a/tests/test_concurrency.py +++ b/tests/test_concurrency.py @@ -17,6 +17,7 @@ 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 @@ def try_some_code(self, code, concurrency, the_module, expected_out=None): 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 @@ def test_eventlet_simple_code(self): 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,59 @@ def do(): """ 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_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") + SQUARE_OR_CUBE_WORK = """ def work(x): @@ -385,7 +440,9 @@ def try_multiprocessing_code( 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 6d756c0c5..2a8d1bb87 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