Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: make third-party detection work with namespace packages. #1231 #1236

Merged
merged 3 commits into from
Oct 11, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,17 @@ This list is detailed and covers changes in each pre-release version.
Unreleased
----------

- Namespace packages being measured weren't properly handled by the new code
that ignores third-party packages. If the namespace package was installed, it
was ignored as a third-party package. That problem (`issue 1231`_) is now
fixed.

- The :meth:`.CoverageData.contexts_by_lineno` method was documented to return
a dict, but was returning a defaultdict. Now it returns a plain dict. It
also no longer returns negative numbered keys.

.. _issue 1231: https://github.com/nedbat/coveragepy/issues/1231


.. _changes_601:

Expand Down
6 changes: 3 additions & 3 deletions coverage/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ def __init__(self, paths, name):
self.name = name

def __repr__(self):
return f"<TreeMatcher {self.name!r} {self.original_paths!r}>"
return f"<TreeMatcher {self.name} {self.original_paths!r}>"

def info(self):
"""A list of strings for displaying when dumping state."""
Expand All @@ -231,7 +231,7 @@ def __init__(self, module_names, name):
self.name = name

def __repr__(self):
return f"<ModuleMatcher {self.name!r} {self.modules!r}>"
return f"<ModuleMatcher {self.name} {self.modules!r}>"

def info(self):
"""A list of strings for displaying when dumping state."""
Expand Down Expand Up @@ -261,7 +261,7 @@ def __init__(self, pats, name):
self.name = name

def __repr__(self):
return f"<FnmatchMatcher {self.name!r} {self.pats!r}>"
return f"<FnmatchMatcher {self.name} {self.pats!r}>"

def info(self):
"""A list of strings for displaying when dumping state."""
Expand Down
41 changes: 32 additions & 9 deletions coverage/inorout.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,17 +107,26 @@ def module_has_file(mod):
return os.path.exists(mod__file__)


def file_for_module(modulename):
"""Find the file for `modulename`, or return None."""
def file_and_path_for_module(modulename):
"""Find the file and search path for `modulename`.

Returns:
filename: The filename of the module, or None.
path: A list (possibly empty) of directories to find submodules in.

"""
filename = None
path = []
try:
spec = importlib.util.find_spec(modulename)
except ImportError:
pass
else:
if spec is not None:
filename = spec.origin
return filename
if spec.origin != "namespace":
filename = spec.origin
path = list(spec.submodule_search_locations or ())
return filename, path


def add_stdlib_paths(paths):
Expand Down Expand Up @@ -263,15 +272,29 @@ def debug(msg):
# third-party package.
for pkg in self.source_pkgs:
try:
modfile = file_for_module(pkg)
debug(f"Imported {pkg} as {modfile}")
modfile, path = file_and_path_for_module(pkg)
debug(f"Imported source package {pkg!r} as {modfile!r}")
except CoverageException as exc:
debug(f"Couldn't import {pkg}: {exc}")
debug(f"Couldn't import source package {pkg!r}: {exc}")
continue
if modfile and self.third_match.match(modfile):
self.source_in_third = True
if modfile:
if self.third_match.match(modfile):
debug(
f"Source is in third-party because of source_pkg {pkg!r} at {modfile!r}"
)
self.source_in_third = True
else:
for pathdir in path:
if self.third_match.match(pathdir):
debug(
f"Source is in third-party because of {pkg!r} path directory " +
f"at {pathdir!r}"
)
self.source_in_third = True

for src in self.source:
if self.third_match.match(src):
debug(f"Source is in third-party because of source directory {src!r}")
self.source_in_third = True

def should_trace(self, filename, frame=None):
Expand Down
80 changes: 74 additions & 6 deletions tests/test_process.py
Original file line number Diff line number Diff line change
Expand Up @@ -817,7 +817,6 @@ def foo():
# Remove the file location and source line from the warning.
out = re.sub(r"(?m)^[\\/\w.:~_-]+:\d+: CoverageWarning: ", "f:d: CoverageWarning: ", out)
out = re.sub(r"(?m)^\s+self.warn.*$\n", "", out)
print("out:", repr(out))
expected = (
"Run 1\n" +
"Run 2\n" +
Expand Down Expand Up @@ -1615,7 +1614,6 @@ def path(basename):
data = coverage.CoverageData()
data.read()
summary = line_counts(data)
print(summary)
assert summary[source + '.py'] == 3
assert len(summary) == 1

Expand Down Expand Up @@ -1693,13 +1691,37 @@ def render(filename, linenum):
def fourth(x):
return 4 * x
""")
# Some namespace packages.
make_file("third_pkg/nspkg/fifth/__init__.py", """\
def fifth(x):
return 5 * x
""")
# The setup.py to install everything.
make_file("third_pkg/setup.py", """\
import setuptools
setuptools.setup(name="third", packages=["third", "fourth"])
setuptools.setup(
name="third",
packages=["third", "fourth", "nspkg.fifth"],
)
""")

# Some namespace packages.
make_file("another_pkg/nspkg/sixth/__init__.py", """\
def sixth(x):
return 6 * x
""")
# The setup.py to install everything.
make_file("another_pkg/setup.py", """\
import setuptools
setuptools.setup(
name="another",
packages=["nspkg.sixth"],
)
""")

# Install the third-party packages.
run_in_venv("python -m pip install --no-index ./third_pkg")
run_in_venv("python -m pip install --no-index -e ./another_pkg")
shutil.rmtree("third_pkg")

# Install coverage.
Expand All @@ -1721,17 +1743,22 @@ def coverage_command_fixture(request):
class VirtualenvTest(CoverageTest):
"""Tests of virtualenv considerations."""

expected_stdout = "33\n110\n198\n1.5\n"

@pytest.fixture(autouse=True)
def in_venv_world_fixture(self, venv_world):
"""For running tests inside venv_world, and cleaning up made files."""
with change_dir(venv_world):
self.make_file("myproduct.py", """\
import colorsys
import third
import nspkg.fifth
import nspkg.sixth
print(third.third(11))
print(nspkg.fifth.fifth(22))
print(nspkg.sixth.sixth(33))
print(sum(colorsys.rgb_to_hls(1, 0, 0)))
""")
self.expected_stdout = "33\n1.5\n" # pylint: disable=attribute-defined-outside-init

self.del_environ("COVERAGE_TESTING") # To avoid needing contracts installed.
self.set_environ("COVERAGE_DEBUG_FILE", "debug_out.txt")
Expand All @@ -1740,7 +1767,7 @@ def in_venv_world_fixture(self, venv_world):
yield

for fname in os.listdir("."):
if fname != "venv":
if fname not in {"venv", "another_pkg"}:
os.remove(fname)

def get_trace_output(self):
Expand Down Expand Up @@ -1785,7 +1812,6 @@ def test_us_in_venv_isnt_measured(self, coverage_command):
r"^Not tracing .*\bexecfile.py': " +
"module 'coverage.execfile' falls outside the --source spec"
)
print(re_lines(debug_out, "myproduct"))
assert re_lines(
debug_out,
r"^Not tracing .*\bmyproduct.py': module 'myproduct' falls outside the --source spec"
Expand Down Expand Up @@ -1832,3 +1858,45 @@ def test_venv_with_dynamic_plugin(self, coverage_command):
# The output should not have this warning:
# Already imported a file that will be measured: ...third/render.py (already-imported)
assert out == "HTML: hello.html@1723\n"

def test_installed_namespace_packages(self, coverage_command):
# https://github.com/nedbat/coveragepy/issues/1231
# When namespace packages were installed, they were considered
# third-party packages. Test that isn't still happening.
out = run_in_venv(coverage_command + " run --source=nspkg myproduct.py")
# In particular, this warning doesn't appear:
# Already imported a file that will be measured: .../coverage/__main__.py
assert out == self.expected_stdout

# Check that our tracing was accurate. Files are mentioned because
# --source refers to a file.
debug_out = self.get_trace_output()
assert re_lines(
debug_out,
r"^Not tracing .*\bexecfile.py': " +
"module 'coverage.execfile' falls outside the --source spec"
)
assert re_lines(
debug_out,
r"^Not tracing .*\bmyproduct.py': module 'myproduct' falls outside the --source spec"
)
assert re_lines(
debug_out,
r"^Not tracing .*\bcolorsys.py': module 'colorsys' falls outside the --source spec"
)

out = run_in_venv("python -m coverage report")

# Name Stmts Miss Cover
# ------------------------------------------------------------------------------
# another_pkg/nspkg/sixth/__init__.py 2 0 100%
# venv/lib/python3.9/site-packages/nspkg/fifth/__init__.py 2 0 100%
# ------------------------------------------------------------------------------
# TOTAL 4 0 100%

assert "myproduct.py" not in out
assert "third" not in out
assert "coverage" not in out
assert "colorsys" not in out
assert "fifth" in out
assert "sixth" in out