Skip to content

Commit b3e87bb

Browse files
committed
fix: be intelligent about third-party packages
Avoid measuring code located where third-party packages get installed. We have to take care to measure --source code even if it is installed in a third-party location. This also fixes #905, coverage generating warnings about coverage being imported when it will be measured. #876 #905
1 parent dc48d27 commit b3e87bb

File tree

5 files changed

+208
-13
lines changed

5 files changed

+208
-13
lines changed

CHANGES.rst

+11
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,21 @@ want to know what's different in 5.0 since 4.5.x, see :ref:`whatsnew5x`.
2424
Unreleased
2525
----------
2626

27+
- Third-party packages are now ignored in coverage reporting. This solves two
28+
problems:
29+
30+
- Coverage will no longer report about other people's code (`issue 876`_).
31+
32+
- Coverage will no longer generate "Already imported a file that will be
33+
measured" warnings about coverage itself (`issue 905`_).
34+
2735
- The JSON report now includes ``percent_covered_display``, a string with the
2836
total percentage, rounded to the same number of decimal places as the other
2937
reports' totals.
3038

39+
.. _issue 876: https://github.com/nedbat/coveragepy/issues/876
40+
.. _issue 905: https://github.com/nedbat/coveragepy/issues/905
41+
3142

3243
.. _changes_55:
3344

coverage/inorout.py

+94-9
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,17 @@
33

44
"""Determining whether files are being measured/reported or not."""
55

6-
# For finding the stdlib
7-
import atexit
86
import inspect
97
import itertools
108
import os
119
import platform
1210
import re
1311
import sys
12+
import sysconfig
1413
import traceback
1514

1615
from coverage import env
17-
from coverage.backward import code_object
16+
from coverage.backward import code_object, importlib_util_find_spec
1817
from coverage.disposition import FileDisposition, disposition_init
1918
from coverage.files import TreeMatcher, FnmatchMatcher, ModuleMatcher
2019
from coverage.files import prep_patterns, find_python_files, canonical_filename
@@ -108,14 +107,53 @@ def module_has_file(mod):
108107
return os.path.exists(mod__file__)
109108

110109

110+
def file_for_module(modulename):
111+
"""Find the file for `modulename`, or return None."""
112+
if importlib_util_find_spec:
113+
filename = None
114+
try:
115+
spec = importlib_util_find_spec(modulename)
116+
except ImportError:
117+
pass
118+
else:
119+
if spec is not None:
120+
filename = spec.origin
121+
return filename
122+
else:
123+
import imp
124+
openfile = None
125+
glo, loc = globals(), locals()
126+
try:
127+
# Search for the module - inside its parent package, if any - using
128+
# standard import mechanics.
129+
if '.' in modulename:
130+
packagename, name = modulename.rsplit('.', 1)
131+
package = __import__(packagename, glo, loc, ['__path__'])
132+
searchpath = package.__path__
133+
else:
134+
packagename, name = None, modulename
135+
searchpath = None # "top-level search" in imp.find_module()
136+
openfile, pathname, _ = imp.find_module(name, searchpath)
137+
return pathname
138+
except ImportError:
139+
return None
140+
finally:
141+
if openfile:
142+
openfile.close()
143+
144+
111145
def add_stdlib_paths(paths):
112146
"""Add paths where the stdlib can be found to the set `paths`."""
113147
# Look at where some standard modules are located. That's the
114148
# indication for "installed with the interpreter". In some
115149
# environments (virtualenv, for example), these modules may be
116150
# spread across a few locations. Look at all the candidate modules
117151
# we've imported, and take all the different ones.
118-
for m in (atexit, inspect, os, platform, _pypy_irc_topic, re, _structseq, traceback):
152+
modules_we_happen_to_have = [
153+
inspect, itertools, os, platform, re, sysconfig, traceback,
154+
_pypy_irc_topic, _structseq,
155+
]
156+
for m in modules_we_happen_to_have:
119157
if m is not None and hasattr(m, "__file__"):
120158
paths.add(canonical_path(m, directory=True))
121159

@@ -129,6 +167,20 @@ def add_stdlib_paths(paths):
129167
paths.add(canonical_path(structseq_file))
130168

131169

170+
def add_third_party_paths(paths):
171+
"""Add locations for third-party packages to the set `paths`."""
172+
# Get the paths that sysconfig knows about.
173+
scheme_names = set(sysconfig.get_scheme_names())
174+
175+
for scheme in scheme_names:
176+
# https://foss.heptapod.net/pypy/pypy/-/issues/3433
177+
better_scheme = "pypy_posix" if scheme == "pypy" else scheme
178+
if os.name in better_scheme.split("_"):
179+
config_paths = sysconfig.get_paths(scheme)
180+
for path_name in ["platlib", "purelib"]:
181+
paths.add(config_paths[path_name])
182+
183+
132184
def add_coverage_paths(paths):
133185
"""Add paths where coverage.py code can be found to the set `paths`."""
134186
cover_path = canonical_path(__file__, directory=True)
@@ -156,8 +208,8 @@ def __init__(self, warn, debug):
156208
# The matchers for should_trace.
157209
self.source_match = None
158210
self.source_pkgs_match = None
159-
self.pylib_paths = self.cover_paths = None
160-
self.pylib_match = self.cover_match = None
211+
self.pylib_paths = self.cover_paths = self.third_paths = None
212+
self.pylib_match = self.cover_match = self.third_match = None
161213
self.include_match = self.omit_match = None
162214
self.plugins = []
163215
self.disp_class = FileDisposition
@@ -168,6 +220,9 @@ def __init__(self, warn, debug):
168220
self.source_pkgs_unmatched = []
169221
self.omit = self.include = None
170222

223+
# Is the source inside a third-party area?
224+
self.source_in_third = False
225+
171226
def configure(self, config):
172227
"""Apply the configuration to get ready for decision-time."""
173228
self.source_pkgs.extend(config.source_pkgs)
@@ -191,6 +246,10 @@ def configure(self, config):
191246
self.cover_paths = set()
192247
add_coverage_paths(self.cover_paths)
193248

249+
# Find where third-party packages are installed.
250+
self.third_paths = set()
251+
add_third_party_paths(self.third_paths)
252+
194253
def debug(msg):
195254
if self.debug:
196255
self.debug.write(msg)
@@ -218,6 +277,24 @@ def debug(msg):
218277
if self.omit:
219278
self.omit_match = FnmatchMatcher(self.omit)
220279
debug("Omit matching: {!r}".format(self.omit_match))
280+
if self.third_paths:
281+
self.third_match = TreeMatcher(self.third_paths)
282+
debug("Third-party lib matching: {!r}".format(self.third_match))
283+
284+
# Check if the source we want to measure has been installed as a
285+
# third-party package.
286+
for pkg in self.source_pkgs:
287+
try:
288+
modfile = file_for_module(pkg)
289+
debug("Imported {} as {}".format(pkg, modfile))
290+
except CoverageException as exc:
291+
debug("Couldn't import {}: {}".format(pkg, exc))
292+
continue
293+
if modfile and self.third_match.match(modfile):
294+
self.source_in_third = True
295+
for src in self.source:
296+
if self.third_match.match(src):
297+
self.source_in_third = True
221298

222299
def should_trace(self, filename, frame=None):
223300
"""Decide whether to trace execution in `filename`, with a reason.
@@ -352,6 +429,9 @@ def check_include_omit_etc(self, filename, frame):
352429
ok = True
353430
if not ok:
354431
return extra + "falls outside the --source spec"
432+
if not self.source_in_third:
433+
if self.third_match.match(filename):
434+
return "inside --source, but in third-party"
355435
elif self.include_match:
356436
if not self.include_match.match(filename):
357437
return "falls outside the --include trees"
@@ -361,6 +441,10 @@ def check_include_omit_etc(self, filename, frame):
361441
if self.pylib_match and self.pylib_match.match(filename):
362442
return "is in the stdlib"
363443

444+
# Exclude anything in the third-party installation areas.
445+
if self.third_match and self.third_match.match(filename):
446+
return "is a third-party module"
447+
364448
# We exclude the coverage.py code itself, since a little of it
365449
# will be measured otherwise.
366450
if self.cover_match and self.cover_match.match(filename):
@@ -485,14 +569,15 @@ def sys_info(self):
485569
Returns a list of (key, value) pairs.
486570
"""
487571
info = [
488-
('cover_paths', self.cover_paths),
489-
('pylib_paths', self.pylib_paths),
572+
("coverage_paths", self.cover_paths),
573+
("stdlib_paths", self.pylib_paths),
574+
("third_party_paths", self.third_paths),
490575
]
491576

492577
matcher_names = [
493578
'source_match', 'source_pkgs_match',
494579
'include_match', 'omit_match',
495-
'cover_match', 'pylib_match',
580+
'cover_match', 'pylib_match', 'third_match',
496581
]
497582

498583
for matcher_name in matcher_names:

coverage/version.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
# This file is exec'ed in setup.py, don't import anything!
66

77
# Same semantics as sys.version_info.
8-
version_info = (5, 5, 1, "alpha", 0)
8+
version_info = (5, 6, 0, "beta", 1)
99

1010

1111
def _make_version(major, minor, micro, releaselevel, serial):

tests/test_debug.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -183,8 +183,9 @@ def test_debug_sys(self):
183183
out_lines = self.f1_debug_output(["sys"])
184184

185185
labels = """
186-
version coverage cover_paths pylib_paths tracer configs_attempted config_file
187-
configs_read data_file python platform implementation executable
186+
version coverage coverage_paths stdlib_paths third_party_paths
187+
tracer configs_attempted config_file configs_read data_file
188+
python platform implementation executable
188189
pid cwd path environment command_line cover_match pylib_match
189190
""".split()
190191
for label in labels:

tests/test_process.py

+99-1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import os
99
import os.path
1010
import re
11+
import shutil
1112
import stat
1213
import sys
1314
import sysconfig
@@ -24,7 +25,7 @@
2425
from coverage.misc import output_encoding
2526

2627
from tests.coveragetest import CoverageTest, TESTS_DIR
27-
from tests.helpers import re_lines
28+
from tests.helpers import change_dir, make_file, nice_file, re_lines, run_command
2829

2930

3031
class ProcessTest(CoverageTest):
@@ -1640,3 +1641,100 @@ def test_dashm_pkg_sub(self):
16401641

16411642
def test_script_pkg_sub(self):
16421643
self.assert_pth_and_source_work_together('', 'pkg', 'sub')
1644+
1645+
1646+
def run_in_venv(args):
1647+
"""Run python with `args` in the "venv" virtualenv.
1648+
1649+
Returns the text output of the command.
1650+
"""
1651+
if env.WINDOWS:
1652+
cmd = r".\venv\Scripts\python.exe "
1653+
else:
1654+
cmd = "./venv/bin/python "
1655+
cmd += args
1656+
status, output = run_command(cmd)
1657+
print(output)
1658+
assert status == 0
1659+
return output
1660+
1661+
1662+
@pytest.fixture(scope="session", name="venv_factory")
1663+
def venv_factory_fixture(tmp_path_factory):
1664+
"""Produce a function which can copy a venv template to a new directory.
1665+
1666+
The function accepts one argument, the directory to use for the venv.
1667+
"""
1668+
tmpdir = tmp_path_factory.mktemp("venv_template")
1669+
with change_dir(str(tmpdir)):
1670+
# Create a virtualenv.
1671+
run_command("python -m virtualenv venv")
1672+
1673+
# A third-party package that installs two different packages.
1674+
make_file("third_pkg/third/__init__.py", """\
1675+
import fourth
1676+
def third(x):
1677+
return 3 * x
1678+
""")
1679+
make_file("third_pkg/fourth/__init__.py", """\
1680+
def fourth(x):
1681+
return 4 * x
1682+
""")
1683+
make_file("third_pkg/setup.py", """\
1684+
import setuptools
1685+
setuptools.setup(name="third", packages=["third", "fourth"])
1686+
""")
1687+
1688+
# Install the third-party packages.
1689+
run_in_venv("-m pip install --no-index ./third_pkg")
1690+
1691+
# Install coverage.
1692+
coverage_src = nice_file(TESTS_DIR, "..")
1693+
run_in_venv("-m pip install --no-index {}".format(coverage_src))
1694+
1695+
def factory(dst):
1696+
"""The venv factory function.
1697+
1698+
Copies the venv template to `dst`.
1699+
"""
1700+
shutil.copytree(str(tmpdir / "venv"), dst, symlinks=(not env.WINDOWS))
1701+
1702+
return factory
1703+
1704+
1705+
class VirtualenvTest(CoverageTest):
1706+
"""Tests of virtualenv considerations."""
1707+
1708+
def setup_test(self):
1709+
self.make_file("myproduct.py", """\
1710+
import third
1711+
print(third.third(11))
1712+
""")
1713+
self.del_environ("COVERAGE_TESTING") # To avoid needing contracts installed.
1714+
super(VirtualenvTest, self).setup_test()
1715+
1716+
def test_third_party_venv_isnt_measured(self, venv_factory):
1717+
venv_factory("venv")
1718+
out = run_in_venv("-m coverage run --source=. myproduct.py")
1719+
# In particular, this warning doesn't appear:
1720+
# Already imported a file that will be measured: .../coverage/__main__.py
1721+
assert out == "33\n"
1722+
out = run_in_venv("-m coverage report")
1723+
assert "myproduct.py" in out
1724+
assert "third" not in out
1725+
1726+
def test_us_in_venv_is_measured(self, venv_factory):
1727+
venv_factory("venv")
1728+
out = run_in_venv("-m coverage run --source=third myproduct.py")
1729+
assert out == "33\n"
1730+
out = run_in_venv("-m coverage report")
1731+
assert "myproduct.py" not in out
1732+
assert "third" in out
1733+
1734+
def test_venv_isnt_measured(self, venv_factory):
1735+
venv_factory("venv")
1736+
out = run_in_venv("-m coverage run myproduct.py")
1737+
assert out == "33\n"
1738+
out = run_in_venv("-m coverage report")
1739+
assert "myproduct.py" in out
1740+
assert "third" not in out

0 commit comments

Comments
 (0)