Skip to content

Commit 2481b7a

Browse files
committed
Ensure that a module within a namespace package can be found by --pyargs.
1 parent 70fdab4 commit 2481b7a

File tree

2 files changed

+95
-35
lines changed

2 files changed

+95
-35
lines changed

_pytest/main.py

Lines changed: 18 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
""" core implementation of testing process: init, session, runtest loop. """
2-
import imp
32
import os
4-
import re
53
import sys
64

75
import _pytest
@@ -25,8 +23,6 @@
2523
EXIT_USAGEERROR = 4
2624
EXIT_NOTESTSCOLLECTED = 5
2725

28-
name_re = re.compile("^[a-zA-Z_]\w*$")
29-
3026
def pytest_addoption(parser):
3127
parser.addini("norecursedirs", "directory patterns to avoid for recursion",
3228
type="args", default=['.*', 'CVS', '_darcs', '{arch}', '*.egg'])
@@ -649,36 +645,29 @@ def _recurse(self, path):
649645
return True
650646

651647
def _tryconvertpyarg(self, x):
652-
mod = None
653-
path = [os.path.abspath('.')] + sys.path
654-
for name in x.split('.'):
655-
# ignore anything that's not a proper name here
656-
# else something like --pyargs will mess up '.'
657-
# since imp.find_module will actually sometimes work for it
658-
# but it's supposed to be considered a filesystem path
659-
# not a package
660-
if name_re.match(name) is None:
661-
return x
662-
try:
663-
fd, mod, type_ = imp.find_module(name, path)
664-
except ImportError:
665-
return x
666-
else:
667-
if fd is not None:
668-
fd.close()
648+
"""Convert a dotted module name to path.
669649
670-
if type_[2] != imp.PKG_DIRECTORY:
671-
path = [os.path.dirname(mod)]
672-
else:
673-
path = [mod]
674-
return mod
650+
"""
651+
import pkgutil
652+
try:
653+
loader = pkgutil.find_loader(x)
654+
except ImportError:
655+
return x
656+
if loader is None:
657+
return x
658+
try:
659+
path = loader.get_filename()
660+
except:
661+
path = loader.modules[x][0].co_filename
662+
if loader.is_package(x):
663+
path = os.path.dirname(path)
664+
return path
675665

676666
def _parsearg(self, arg):
677667
""" return (fspath, names) tuple after checking the file exists. """
678-
arg = str(arg)
679-
if self.config.option.pyargs:
680-
arg = self._tryconvertpyarg(arg)
681668
parts = str(arg).split("::")
669+
if self.config.option.pyargs:
670+
parts[0] = self._tryconvertpyarg(parts[0])
682671
relpath = parts[0].replace("/", os.sep)
683672
path = self.config.invocation_dir.join(relpath, abs=True)
684673
if not path.check():

testing/acceptance_test.py

Lines changed: 77 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
# -*- coding: utf-8 -*-
12
import sys
23

34
import _pytest._code
@@ -514,11 +515,20 @@ def test_pyargs_importerror(self, testdir, monkeypatch):
514515

515516
result = testdir.runpytest("--pyargs", "tpkg.test_hello")
516517
assert result.ret != 0
517-
# FIXME: It would be more natural to match NOT
518-
# "ERROR*file*or*package*not*found*".
519-
result.stdout.fnmatch_lines([
520-
"*collected 0 items*"
521-
])
518+
519+
# Depending on whether the process running the test is the
520+
# same as the process parsing the command-line arguments, the
521+
# type of failure can be different:
522+
if result.stderr.str() == '':
523+
# Different processes
524+
result.stdout.fnmatch_lines([
525+
"collected*0*items*/*1*errors"
526+
])
527+
else:
528+
# Same process
529+
result.stderr.fnmatch_lines([
530+
"ERROR:*file*or*package*not*found:*tpkg.test_hello"
531+
])
522532

523533
def test_cmdline_python_package(self, testdir, monkeypatch):
524534
monkeypatch.delenv('PYTHONDONTWRITEBYTECODE', False)
@@ -557,6 +567,68 @@ def join_pythonpath(what):
557567
"*not*found*test_hello*",
558568
])
559569

570+
def test_cmdline_python_namespace_package(self, testdir, monkeypatch):
571+
"""
572+
test --pyargs option with namespace packages (#1567)
573+
"""
574+
monkeypatch.delenv('PYTHONDONTWRITEBYTECODE', raising=False)
575+
576+
search_path = []
577+
for dirname in "hello", "world":
578+
d = testdir.mkdir(dirname)
579+
search_path.append(d)
580+
ns = d.mkdir("ns_pkg")
581+
ns.join("__init__.py").write(
582+
"__import__('pkg_resources').declare_namespace(__name__)")
583+
lib = ns.mkdir(dirname)
584+
lib.ensure("__init__.py")
585+
lib.join("test_{0}.py".format(dirname)). \
586+
write("def test_{0}(): pass\n"
587+
"def test_other():pass".format(dirname))
588+
589+
# The structure of the test directory is now:
590+
# .
591+
# ├── hello
592+
# │   └── ns_pkg
593+
# │   ├── __init__.py
594+
# │   └── hello
595+
# │   ├── __init__.py
596+
# │   └── test_hello.py
597+
# └── world
598+
# └── ns_pkg
599+
# ├── __init__.py
600+
# └── world
601+
# ├── __init__.py
602+
# └── test_world.py
603+
604+
def join_pythonpath(*dirs):
605+
cur = py.std.os.environ.get('PYTHONPATH')
606+
if cur:
607+
dirs += (cur,)
608+
return ':'.join(str(p) for p in dirs)
609+
monkeypatch.setenv('PYTHONPATH', join_pythonpath(*search_path))
610+
for p in search_path:
611+
monkeypatch.syspath_prepend(p)
612+
613+
# mixed module and filenames:
614+
result = testdir.runpytest("--pyargs", "-v", "ns_pkg.hello", "world/ns_pkg")
615+
assert result.ret == 0
616+
result.stdout.fnmatch_lines([
617+
"*test_hello.py::test_hello*PASSED",
618+
"*test_hello.py::test_other*PASSED",
619+
"*test_world.py::test_world*PASSED",
620+
"*test_world.py::test_other*PASSED",
621+
"*4 passed*"
622+
])
623+
624+
# specify tests within a module
625+
result = testdir.runpytest("--pyargs", "-v", "ns_pkg.world.test_world::test_other")
626+
assert result.ret == 0
627+
result.stdout.fnmatch_lines([
628+
"*test_world.py::test_other*PASSED",
629+
"*1 passed*"
630+
])
631+
560632
def test_cmdline_python_package_not_exists(self, testdir):
561633
result = testdir.runpytest("--pyargs", "tpkgwhatv")
562634
assert result.ret
@@ -697,4 +769,3 @@ def test_setup_function(self, testdir):
697769
* setup *test_1*
698770
* call *test_1*
699771
""")
700-

0 commit comments

Comments
 (0)