Skip to content

Ensure that a module within a namespace package can be found by --pyargs #1568

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

Closed
wants to merge 6 commits into from
Closed
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
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ Ronny Pfannschmidt
Ross Lawley
Ryan Wooden
Samuele Pedroni
Stefano Taschini
Tom Viner
Trevor Bekolay
Wouter van Ackooy
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@

*

* Ensure that a module within a namespace package can be found when it
is specified on the command line together with the``--pyargs``
option.

* Fix win32 path issue when puttinging custom config file with absolute path
in ``pytest.main("-c your_absolute_path")``.

Expand Down
69 changes: 51 additions & 18 deletions _pytest/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -648,8 +648,48 @@ def _recurse(self, path):
ihook.pytest_collect_directory(path=path, parent=self)
return True

def _locate_module(self, modulename, searchpath):
"""Find the locations of a module or package in the filesytem.

In case of a presumptive namespace package return all of its possible
locations.

Note: The only reliable way to determine whether a package is a
namespace package, i.e., whether its ``__path__`` has more than one
element, is to import it. This method does not do that and hence we
are talking of a *presumptive* namespace package. The ``_parsearg``
method is aware of this and, quite conservatively, tends to raise an
exception in case of doubt.

"""
try:
fd, pathname, type_ = imp.find_module(modulename, searchpath)
except ImportError:
return []
else:
if fd is not None:
fd.close()
if type_[2] != imp.PKG_DIRECTORY:
return [pathname]
else:
init_file = os.path.join(pathname, '__init__.py')
# The following check is a little heuristic do determine whether a
# package is a namespace package. If its '__init__.py' is empty
# then it should be treated as a regular package (see #1568 for
# further discussion):
if os.path.getsize(init_file) == 0:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please put a comment explaining why do you check for the file size at this point?

return [pathname]
return [pathname] + self._locate_module(
modulename,
searchpath[searchpath.index(os.path.dirname(pathname)) + 1:])

def _tryconvertpyarg(self, x):
mod = None
"""Convert a dotted module name to a list of file-system locations.

Always return a list. If the module cannot be found, the list contains
just the given argument.

"""
path = [os.path.abspath('.')] + sys.path
for name in x.split('.'):
# ignore anything that's not a proper name here
Expand All @@ -658,27 +698,20 @@ def _tryconvertpyarg(self, x):
# but it's supposed to be considered a filesystem path
# not a package
if name_re.match(name) is None:
return x
try:
fd, mod, type_ = imp.find_module(name, path)
except ImportError:
return x
else:
if fd is not None:
fd.close()

if type_[2] != imp.PKG_DIRECTORY:
path = [os.path.dirname(mod)]
else:
path = [mod]
return mod
return [x]
path = self._locate_module(name, path)
if len(path) == 0:
return [x]
return path

def _parsearg(self, arg):
""" return (fspath, names) tuple after checking the file exists. """
arg = str(arg)
if self.config.option.pyargs:
arg = self._tryconvertpyarg(arg)
parts = str(arg).split("::")
if self.config.option.pyargs:
paths = self._tryconvertpyarg(parts[0])
if len(paths) != 1:
raise pytest.UsageError("Cannot uniquely resolve package directory: " + arg)
parts[0] = paths[0]
relpath = parts[0].replace("/", os.sep)
path = self.config.invocation_dir.join(relpath, abs=True)
if not path.check():
Expand Down
71 changes: 70 additions & 1 deletion testing/acceptance_test.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
import sys

import _pytest._code
Expand Down Expand Up @@ -557,6 +558,75 @@ def join_pythonpath(what):
"*not*found*test_hello*",
])

def test_cmdline_python_namespace_package(self, testdir, monkeypatch):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small nitpick: add a docstring which a comment pointing to the issue:

def test_cmdline_python_namespace_package(self, testdir, monkeypatch):
    """
    test --pyargs option with namespace packages (#1567)
    """

"""
test --pyargs option with namespace packages (#1567)
"""
monkeypatch.delenv('PYTHONDONTWRITEBYTECODE', raising=False)

search_path = []
for dirname in "hello", "world":
d = testdir.mkdir(dirname)
search_path.append(d)
ns = d.mkdir("ns_pkg")
ns.join("__init__.py").write(
"__import__('pkg_resources').declare_namespace(__name__)")
lib = ns.mkdir(dirname)
lib.ensure("__init__.py")
lib.join("test_{0}.py".format(dirname)). \
write("def test_{0}(): pass\n"
"def test_other():pass".format(dirname))

# The structure of the test directory is now:
# .
# ├── hello
# │   └── ns_pkg
# │   ├── __init__.py
# │   └── hello
# │   ├── __init__.py
# │   └── test_hello.py
# └── world
# └── ns_pkg
# ├── __init__.py
# └── world
# ├── __init__.py
# └── test_world.py

def join_pythonpath(*dirs):
cur = py.std.os.environ.get('PYTHONPATH')
if cur:
dirs += (cur,)
return ':'.join(str(p) for p in dirs)
monkeypatch.setenv('PYTHONPATH', join_pythonpath(*search_path))
for p in search_path:
monkeypatch.syspath_prepend(p)

# mixed module and filenames:
result = testdir.runpytest("--pyargs", "-v", "ns_pkg.hello", "world/ns_pkg")
assert result.ret == 0
result.stdout.fnmatch_lines([
"*test_hello.py::test_hello*PASSED",
"*test_hello.py::test_other*PASSED",
"*test_world.py::test_world*PASSED",
"*test_world.py::test_other*PASSED",
"*4 passed*"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be better to run pytest with -v and check for the actual test names, to ensure we are not missing anything or introduce faulty behavior in the future:

result.stdout.fnmatch_lines([
    "*test_hello.py::test_hello*PASSED",
    "*test_hello.py::test_other*PASSED",
    "*test_world.py::test_world*PASSED",
    "*test_world.py::test_other*PASSED",
])

(the same comment apply to the next checks done here)

])

# specify tests within a module
result = testdir.runpytest("--pyargs", "-v", "ns_pkg.world.test_world::test_other")
assert result.ret == 0
result.stdout.fnmatch_lines([
"*test_world.py::test_other*PASSED",
"*1 passed*"
])

# namespace package
result = testdir.runpytest("--pyargs", "ns_pkg")
assert result.ret
result.stderr.fnmatch_lines([
"ERROR:*Cannot*uniquely*resolve*package*directory:*ns_pkg",
])

def test_cmdline_python_package_not_exists(self, testdir):
result = testdir.runpytest("--pyargs", "tpkgwhatv")
assert result.ret
Expand Down Expand Up @@ -697,4 +767,3 @@ def test_setup_function(self, testdir):
* setup *test_1*
* call *test_1*
""")