diff options
-rw-r--r-- | CHANGES.rst | 3 | ||||
-rw-r--r-- | coverage/sqldata.py | 20 | ||||
-rw-r--r-- | tests/test_data.py | 7 |
3 files changed, 29 insertions, 1 deletions
diff --git a/CHANGES.rst b/CHANGES.rst index 29af7340..3c65e5d8 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -26,6 +26,9 @@ Unreleased - Dropped support for Python 2.7, PyPy 2, and Python 3.5. +- Data collection is now thread-safe. There may have been rare instances of + exceptions raised in multi-threaded programs. + - Plugins (like the `Django coverage plugin`_) were generating "Already imported a file that will be measured" warnings about Django itself. These have been fixed, closing `issue 1150`_. diff --git a/coverage/sqldata.py b/coverage/sqldata.py index 14279518..0b606d03 100644 --- a/coverage/sqldata.py +++ b/coverage/sqldata.py @@ -8,6 +8,7 @@ import collections import datetime +import functools import glob import itertools import os @@ -179,6 +180,10 @@ class CoverageData(SimpleReprMixin): Data in a :class:`CoverageData` can be serialized and deserialized with :meth:`dumps` and :meth:`loads`. + The methods used during the coverage.py collection phase + (:meth:`add_lines`, :meth:`add_arcs`, :meth:`set_context`, and + :meth:`add_file_tracers`) are thread-safe. Other methods may not be. + """ def __init__(self, basename=None, suffix=None, no_disk=False, warn=None, debug=None): @@ -207,6 +212,8 @@ class CoverageData(SimpleReprMixin): # Maps thread ids to SqliteDb objects. self._dbs = {} self._pid = os.getpid() + # Synchronize the operations used during collection. + self._lock = threading.Lock() # Are we in sync with the data file? self._have_used = False @@ -218,6 +225,15 @@ class CoverageData(SimpleReprMixin): self._current_context_id = None self._query_context_ids = None + def _locked(method): # pylint: disable=no-self-argument + """A decorator for methods that should hold self._lock.""" + @functools.wraps(method) + def _wrapped(self, *args, **kwargs): + with self._lock: + # pylint: disable=not-callable + return method(self, *args, **kwargs) + return _wrapped + def _choose_filename(self): """Set self._filename based on inited attributes.""" if self._no_disk: @@ -388,6 +404,7 @@ class CoverageData(SimpleReprMixin): else: return None + @_locked def set_context(self, context): """Set the current context for future :meth:`add_lines` etc. @@ -429,6 +446,7 @@ class CoverageData(SimpleReprMixin): """ return self._filename + @_locked def add_lines(self, line_data): """Add measured line data. @@ -461,6 +479,7 @@ class CoverageData(SimpleReprMixin): (file_id, self._current_context_id, linemap), ) + @_locked def add_arcs(self, arc_data): """Add measured arc data. @@ -505,6 +524,7 @@ class CoverageData(SimpleReprMixin): ('has_arcs', str(int(arcs))) ) + @_locked def add_file_tracers(self, file_tracers): """Add per-file plugin information. diff --git a/tests/test_data.py b/tests/test_data.py index 4b385b7f..be978e5e 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -486,10 +486,14 @@ class CoverageDataTest(DataTestHelpers, CoverageTest): def test_thread_stress(self): covdata = CoverageData() + exceptions = [] def thread_main(): """Every thread will try to add the same data.""" - covdata.add_lines(LINES_1) + try: + covdata.add_lines(LINES_1) + except Exception as ex: + exceptions.append(ex) threads = [threading.Thread(target=thread_main) for _ in range(10)] for t in threads: @@ -498,6 +502,7 @@ class CoverageDataTest(DataTestHelpers, CoverageTest): t.join() self.assert_lines1_data(covdata) + assert exceptions == [] class CoverageDataInTempDirTest(DataTestHelpers, CoverageTest): |