Skip to content

Commit 2603597

Browse files
committed
fix: source modules need to be re-imported. #1232
1 parent fdaa822 commit 2603597

File tree

7 files changed

+78
-42
lines changed

7 files changed

+78
-42
lines changed

Diff for: CHANGES.rst

+9
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,20 @@ Unreleased
2727
was ignored as a third-party package. That problem (`issue 1231`_) is now
2828
fixed.
2929

30+
- Packages named as "source packages" (with ``source``, or ``source_pkgs``, or
31+
pytest-cov's ``--cov``) might have been only partially measured. Their
32+
top-level statements could be marked as unexecuted, because they were
33+
imported by coverage.py before measurement began (`issue 1232`_). This is
34+
now fixed, but the package will be imported twice, once by coverage.py, then
35+
again by your test suite. This could cause problems if importing the package
36+
has side effects.
37+
3038
- The :meth:`.CoverageData.contexts_by_lineno` method was documented to return
3139
a dict, but was returning a defaultdict. Now it returns a plain dict. It
3240
also no longer returns negative numbered keys.
3341

3442
.. _issue 1231: https://github.com/nedbat/coveragepy/issues/1231
43+
.. _issue 1232: https://github.com/nedbat/coveragepy/issues/1232
3544

3645

3746
.. _changes_601:

Diff for: coverage/inorout.py

+20-18
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from coverage.exceptions import CoverageException
1919
from coverage.files import TreeMatcher, FnmatchMatcher, ModuleMatcher
2020
from coverage.files import prep_patterns, find_python_files, canonical_filename
21+
from coverage.misc import sys_modules_saved
2122
from coverage.python import source_for_file, source_for_morf
2223

2324

@@ -270,27 +271,28 @@ def debug(msg):
270271

271272
# Check if the source we want to measure has been installed as a
272273
# third-party package.
273-
for pkg in self.source_pkgs:
274-
try:
275-
modfile, path = file_and_path_for_module(pkg)
276-
debug(f"Imported source package {pkg!r} as {modfile!r}")
277-
except CoverageException as exc:
278-
debug(f"Couldn't import source package {pkg!r}: {exc}")
279-
continue
280-
if modfile:
281-
if self.third_match.match(modfile):
282-
debug(
283-
f"Source is in third-party because of source_pkg {pkg!r} at {modfile!r}"
284-
)
285-
self.source_in_third = True
286-
else:
287-
for pathdir in path:
288-
if self.third_match.match(pathdir):
274+
with sys_modules_saved():
275+
for pkg in self.source_pkgs:
276+
try:
277+
modfile, path = file_and_path_for_module(pkg)
278+
debug(f"Imported source package {pkg!r} as {modfile!r}")
279+
except CoverageException as exc:
280+
debug(f"Couldn't import source package {pkg!r}: {exc}")
281+
continue
282+
if modfile:
283+
if self.third_match.match(modfile):
289284
debug(
290-
f"Source is in third-party because of {pkg!r} path directory " +
291-
f"at {pathdir!r}"
285+
f"Source is in third-party because of source_pkg {pkg!r} at {modfile!r}"
292286
)
293287
self.source_in_third = True
288+
else:
289+
for pathdir in path:
290+
if self.third_match.match(pathdir):
291+
debug(
292+
f"Source is in third-party because of {pkg!r} path directory " +
293+
f"at {pathdir!r}"
294+
)
295+
self.source_in_third = True
294296

295297
for src in self.source:
296298
if self.third_match.match(src):

Diff for: coverage/misc.py

+28-10
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
"""Miscellaneous stuff for coverage.py."""
55

6+
import contextlib
67
import errno
78
import hashlib
89
import importlib
@@ -49,6 +50,28 @@ def isolate_module(mod):
4950
os = isolate_module(os)
5051

5152

53+
class SysModuleSaver:
54+
"""Saves the contents of sys.modules, and removes new modules later."""
55+
def __init__(self):
56+
self.old_modules = set(sys.modules)
57+
58+
def restore(self):
59+
"""Remove any modules imported since this object started."""
60+
new_modules = set(sys.modules) - self.old_modules
61+
for m in new_modules:
62+
del sys.modules[m]
63+
64+
65+
@contextlib.contextmanager
66+
def sys_modules_saved():
67+
"""A context manager to remove any modules imported during a block."""
68+
saver = SysModuleSaver()
69+
try:
70+
yield
71+
finally:
72+
saver.restore()
73+
74+
5275
def import_third_party(modname):
5376
"""Import a third-party module we need, but might not be installed.
5477
@@ -63,16 +86,11 @@ def import_third_party(modname):
6386
The imported module, or None if the module couldn't be imported.
6487
6588
"""
66-
try:
67-
mod = importlib.import_module(modname)
68-
except ImportError:
69-
mod = None
70-
71-
imported = [m for m in sys.modules if m.startswith(modname)]
72-
for name in imported:
73-
del sys.modules[name]
74-
75-
return mod
89+
with sys_modules_saved():
90+
try:
91+
return importlib.import_module(modname)
92+
except ImportError:
93+
return None
7694

7795

7896
def dummy_decorator_with_args(*args_unused, **kwargs_unused):

Diff for: coverage/tomlconfig.py

+5-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@
1010
from coverage.exceptions import CoverageException
1111
from coverage.misc import import_third_party, substitute_variables
1212

13-
# TOML support is an install-time extra option.
13+
# TOML support is an install-time extra option. (Import typing is here because
14+
# import_third_party will unload any module that wasn't already imported.
15+
# tomli imports typing, and if we unload it, later it's imported again, and on
16+
# Python 3.6, this causes infinite recursion.)
17+
import typing # pylint: disable=unused-import, wrong-import-order
1418
tomli = import_third_party("tomli")
1519

1620

Diff for: doc/source.rst

+5
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ in their names will be skipped (they are assumed to be scratch files written by
3939
text editors). Files that do not end with ``.py``, ``.pyw``, ``.pyo``, or
4040
``.pyc`` will also be skipped.
4141

42+
.. note:: Modules named as sources may be imported twice, once by coverage.py
43+
to find their location, then again by your own code or test suite. Usually
44+
this isn't a problem, but could cause trouble if a module has side-effects
45+
at import time.
46+
4247
You can further fine-tune coverage.py's attention with the ``--include`` and
4348
``--omit`` switches (or ``[run] include`` and ``[run] omit`` configuration
4449
values). ``--include`` is a list of file name patterns. If specified, only

Diff for: tests/mixins.py

+4-13
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
import pytest
1717

18+
from coverage.misc import SysModuleSaver
1819
from tests.helpers import change_dir, make_file, remove_files
1920

2021

@@ -96,21 +97,11 @@ def _save_sys_path(self):
9697
@pytest.fixture(autouse=True)
9798
def _module_saving(self):
9899
"""Remove modules we imported during the test."""
99-
self._old_modules = list(sys.modules)
100+
self._sys_module_saver = SysModuleSaver()
100101
try:
101102
yield
102103
finally:
103-
self._cleanup_modules()
104-
105-
def _cleanup_modules(self):
106-
"""Remove any new modules imported since our construction.
107-
108-
This lets us import the same source files for more than one test, or
109-
if called explicitly, within one test.
110-
111-
"""
112-
for m in [m for m in sys.modules if m not in self._old_modules]:
113-
del sys.modules[m]
104+
self._sys_module_saver.restore()
114105

115106
def clean_local_file_imports(self):
116107
"""Clean up the results of calls to `import_local_file`.
@@ -120,7 +111,7 @@ def clean_local_file_imports(self):
120111
121112
"""
122113
# So that we can re-import files, clean them out first.
123-
self._cleanup_modules()
114+
self._sys_module_saver.restore()
124115

125116
# Also have to clean out the .pyc file, since the timestamp
126117
# resolution is only one second, a changed file might not be

Diff for: tests/test_misc.py

+7
Original file line numberDiff line numberDiff line change
@@ -165,8 +165,15 @@ class ImportThirdPartyTest(CoverageTest):
165165
run_in_temp_dir = False
166166

167167
def test_success(self):
168+
# Make sure we don't have pytest in sys.modules before we start.
169+
del sys.modules["pytest"]
170+
# Import pytest
168171
mod = import_third_party("pytest")
172+
# Yes, it's really pytest:
169173
assert mod.__name__ == "pytest"
174+
print(dir(mod))
175+
assert all(hasattr(mod, name) for name in ["skip", "mark", "raises", "warns"])
176+
# But it's not in sys.modules:
170177
assert "pytest" not in sys.modules
171178

172179
def test_failure(self):

0 commit comments

Comments
 (0)