Repository: nose-devs/nose2

Issue:
nose2 cant import tests from package
I have set up a virtual env and have a test structure that looks something like this:
<path>/test_package/__init__.py
                                 test_foo.py:test_bar

test_bar is a really simple test, it just passes.
When I stand in "<path>/test_package" and run "nose2" I get an error:
"ImportError: No module named test_package.test_foo"
But it works with nosetests.
And if I stand in "<path>" and run "nose2 test_package" it works, and it also works if I remove the __init__.py

I know that the collection of tests work a little different than in nosetests, but I think that it would be good to make this work since it would make it easier to create a test package and run it.

Discussion:

Patch:
diff --git a/nose2/plugins/loader/discovery.py b/nose2/plugins/loader/discovery.py
index 1ec69d6..73aa0fa 100644
--- a/nose2/plugins/loader/discovery.py
+++ b/nose2/plugins/loader/discovery.py
@@ -194,8 +194,8 @@ class Discoverer(object):
             return
 
         if module_name is None:
-            module_name = util.name_from_path(full_path)
-        
+            module_name, package_path = util.name_from_path(full_path)
+            util.ensure_importable(package_path)
         try:
             module = util.module_from_name(module_name)
         except:
diff --git a/nose2/util.py b/nose2/util.py
index dbc14ab..7b615a9 100644
--- a/nose2/util.py
+++ b/nose2/util.py
@@ -59,7 +59,15 @@ def valid_module_name(path):
 
 
 def name_from_path(path):
-    """Translate path into module name"""
+    """Translate path into module name
+
+    Returns a two-element tuple. The first element is a dotted
+    module name that can be used in import statement,
+    e.g. pkg1.test.test_things.
+    The second element is a full path to filesystem directory
+    that must be on sys.path in order for the import to succeed.
+
+    """
     # back up to find module root
     parts = []
     path = os.path.normpath(path)
@@ -72,7 +80,7 @@ def name_from_path(path):
             parts.append(top)
         else:
             break
-    return '.'.join(reversed(parts))
+    return '.'.join(reversed(parts)), candidate
 
 
 def module_from_name(name):
@@ -156,7 +164,7 @@ def ispackage(path):
 
 def ensure_importable(dirname):
     """Ensure a directory is on sys.path"""
-    if not dirname in sys.path:
+    if dirname not in sys.path:
         sys.path.insert(0, dirname)
 
 


Test patch:
diff --git a/nose2/plugins/doctests.py b/nose2/plugins/doctests.py
index 0e8aa35..b78df2b 100644
--- a/nose2/plugins/doctests.py
+++ b/nose2/plugins/doctests.py
@@ -41,7 +41,8 @@ class DocTestLoader(Plugin):
         elif not util.valid_module_name(os.path.basename(path)):
             return
 
-        name = util.name_from_path(path)
+        name, package_path = util.name_from_path(path)
+        util.ensure_importable(package_path)
         try:
             module = util.module_from_name(name)
         except Exception:
diff --git a/nose2/plugins/loader/loadtests.py b/nose2/plugins/loader/loadtests.py
index c2b3e10..216c5d0 100644
--- a/nose2/plugins/loader/loadtests.py
+++ b/nose2/plugins/loader/loadtests.py
@@ -67,7 +67,7 @@ class LoadTestsLoader(events.Plugin):
 
         if (self._match(event.name, event.pattern) and
             util.ispackage(event.path)):
-            name = util.name_from_path(event.path)
+            name, _package_path = util.name_from_path(event.path)
             module = util.module_from_name(name)
 
             load_tests = getattr(module, 'load_tests', None)
diff --git a/nose2/tests/functional/support/scenario/doctests/docs.py b/nose2/tests/functional/support/scenario/doctests/docs.py
new file mode 100644
index 0000000..9741ac0
--- /dev/null
+++ b/nose2/tests/functional/support/scenario/doctests/docs.py
@@ -0,0 +1,4 @@
+"""
+>>> 2 == 2
+True
+"""
diff --git a/nose2/tests/functional/support/scenario/doctests/docs.rst b/nose2/tests/functional/support/scenario/doctests/docs.rst
new file mode 100644
index 0000000..1c0deeb
--- /dev/null
+++ b/nose2/tests/functional/support/scenario/doctests/docs.rst
@@ -0,0 +1,2 @@
+>>> 1 == 1
+True
diff --git a/nose2/tests/functional/support/scenario/doctests/docs.txt b/nose2/tests/functional/support/scenario/doctests/docs.txt
new file mode 100644
index 0000000..05fe5dc
--- /dev/null
+++ b/nose2/tests/functional/support/scenario/doctests/docs.txt
@@ -0,0 +1,4 @@
+>>> 2 == 2
+True
+>>> 3 == 2
+False
diff --git a/nose2/tests/functional/support/scenario/doctests/doctests_pkg1/__init__.py b/nose2/tests/functional/support/scenario/doctests/doctests_pkg1/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/nose2/tests/functional/support/scenario/doctests/doctests_pkg1/docs1.py b/nose2/tests/functional/support/scenario/doctests/doctests_pkg1/docs1.py
new file mode 100644
index 0000000..9741ac0
--- /dev/null
+++ b/nose2/tests/functional/support/scenario/doctests/doctests_pkg1/docs1.py
@@ -0,0 +1,4 @@
+"""
+>>> 2 == 2
+True
+"""
diff --git a/nose2/tests/functional/support/scenario/doctests/doctests_pkg1/docs1.rst b/nose2/tests/functional/support/scenario/doctests/doctests_pkg1/docs1.rst
new file mode 100644
index 0000000..1c0deeb
--- /dev/null
+++ b/nose2/tests/functional/support/scenario/doctests/doctests_pkg1/docs1.rst
@@ -0,0 +1,2 @@
+>>> 1 == 1
+True
diff --git a/nose2/tests/functional/support/scenario/doctests/doctests_pkg1/docs1.txt b/nose2/tests/functional/support/scenario/doctests/doctests_pkg1/docs1.txt
new file mode 100644
index 0000000..05fe5dc
--- /dev/null
+++ b/nose2/tests/functional/support/scenario/doctests/doctests_pkg1/docs1.txt
@@ -0,0 +1,4 @@
+>>> 2 == 2
+True
+>>> 3 == 2
+False
diff --git a/nose2/tests/functional/test_doctests_plugin.py b/nose2/tests/functional/test_doctests_plugin.py
new file mode 100644
index 0000000..9a78db0
--- /dev/null
+++ b/nose2/tests/functional/test_doctests_plugin.py
@@ -0,0 +1,60 @@
+from nose2.tests._common import FunctionalTestCase, support_file
+
+
+class TestDoctestsPlugin(FunctionalTestCase):
+
+    def test_simple(self):
+        proc = self.runIn(
+            'scenario/doctests',
+            '-v',
+            '--plugin=nose2.plugins.doctests',
+            '--with-doctest',
+        )
+        self.assertTestRunOutputMatches(proc, stderr='Ran 6 tests')
+        self.assertTestRunOutputMatches(
+            proc, stderr='Doctest: docs ... ok')
+        self.assertTestRunOutputMatches(
+            proc, stderr='Doctest: docs.rst ... ok')
+        self.assertTestRunOutputMatches(
+            proc, stderr='Doctest: docs.txt ... ok')
+        self.assertTestRunOutputMatches(
+            proc, stderr='Doctest: doctests_pkg1.docs1 ... ok')
+        self.assertTestRunOutputMatches(
+            proc, stderr='Doctest: docs1.rst ... ok')
+        self.assertTestRunOutputMatches(
+            proc, stderr='Doctest: docs1.txt ... ok')
+        self.assertEqual(proc.poll(), 0)
+
+    def test_start_directory_inside_package(self):
+        proc = self.runIn(
+            'scenario/doctests/doctests_pkg1',
+            '-v',
+            '--plugin=nose2.plugins.doctests',
+            '--with-doctest',
+            '-t',
+            support_file('scenario/doctests')
+        )
+        self.assertTestRunOutputMatches(proc, stderr='Ran 3 tests')
+        self.assertTestRunOutputMatches(
+            proc, stderr='Doctest: doctests_pkg1.docs1 ... ok')
+        self.assertTestRunOutputMatches(
+            proc, stderr='Doctest: docs1.rst ... ok')
+        self.assertTestRunOutputMatches(
+            proc, stderr='Doctest: docs1.txt ... ok')
+        self.assertEqual(proc.poll(), 0)
+
+    def test_project_directory_inside_package(self):
+        proc = self.runIn(
+            'scenario/doctests/doctests_pkg1',
+            '-v',
+            '--plugin=nose2.plugins.doctests',
+            '--with-doctest',
+        )
+        self.assertTestRunOutputMatches(proc, stderr='Ran 3 tests')
+        self.assertTestRunOutputMatches(
+            proc, stderr='Doctest: doctests_pkg1.docs1 ... ok')
+        self.assertTestRunOutputMatches(
+            proc, stderr='Doctest: docs1.rst ... ok')
+        self.assertTestRunOutputMatches(
+            proc, stderr='Doctest: docs1.txt ... ok')
+        self.assertEqual(proc.poll(), 0)
diff --git a/nose2/tests/functional/test_loading.py b/nose2/tests/functional/test_loading.py
index e33029f..c169539 100644
--- a/nose2/tests/functional/test_loading.py
+++ b/nose2/tests/functional/test_loading.py
@@ -22,6 +22,22 @@ from nose2.tests._common import FunctionalTestCase, support_file
 
 class TestLoadTestsFromPackage(FunctionalTestCase):
 
+    def test_start_directory_inside_package(self):
+        proc = self.runIn(
+            'scenario/tests_in_package/pkg1/test',
+            '-v',
+            '-t',
+            support_file('scenario/tests_in_package'))
+        self.assertTestRunOutputMatches(proc, stderr='Ran 25 tests')
+        self.assertEqual(proc.poll(), 1)
+
+    def test_project_directory_inside_package(self):
+        proc = self.runIn(
+            'scenario/tests_in_package/pkg1/test',
+            '-v')
+        self.assertTestRunOutputMatches(proc, stderr='Ran 25 tests')
+        self.assertEqual(proc.poll(), 1)
+
     def test_module_name(self):
         proc = self.runIn(
             'scenario/tests_in_package',
diff --git a/nose2/tests/functional/test_loadtests_plugin.py b/nose2/tests/functional/test_loadtests_plugin.py
index 8063711..a559113 100644
--- a/nose2/tests/functional/test_loadtests_plugin.py
+++ b/nose2/tests/functional/test_loadtests_plugin.py
@@ -29,3 +29,14 @@ class TestLoadTestsPlugin(FunctionalTestCase):
             proc, stderr='test..ltpkg.tests.test_find_these.Test')
         self.assertTestRunOutputMatches(
             proc, stderr='test..ltpkg2.tests.Test')
+
+    def test_project_directory_inside_package(self):
+        proc = self.runIn(
+            'scenario/load_tests_pkg/ltpkg/tests',
+            '-v',
+            '-c='
+            'nose2/tests/functional/support/scenario/load_tests_pkg/unittest.cfg',
+            '--plugin=nose2.plugins.loader.loadtests')
+        self.assertTestRunOutputMatches(proc, stderr='Ran 1 test')
+        self.assertTestRunOutputMatches(
+            proc, stderr='test..ltpkg.tests.test_find_these.Test')
diff --git a/nose2/tests/functional/test_util.py b/nose2/tests/functional/test_util.py
index db4326d..d0d215d 100644
--- a/nose2/tests/functional/test_util.py
+++ b/nose2/tests/functional/test_util.py
@@ -5,5 +5,9 @@ from nose2 import util
 class UtilTests(TestCase):
 
     def test_name_from_path(self):
+        test_module = support_file('scenario/tests_in_package/pkg1/test/test_things.py')
+        test_package_path = support_file('scenario/tests_in_package')
         self.assertEqual(
-            util.name_from_path(support_file('scenario/tests_in_package/pkg1/test/test_things.py')), 'pkg1.test.test_things')
+            util.name_from_path(test_module),
+            ('pkg1.test.test_things', test_package_path)
+        )
diff --git a/nose2/tests/unit/test_util.py b/nose2/tests/unit/test_util.py
new file mode 100644
index 0000000..e1deace
--- /dev/null
+++ b/nose2/tests/unit/test_util.py
@@ -0,0 +1,16 @@
+import os
+import sys
+
+from nose2.tests._common import TestCase
+from nose2 import util
+
+
+class UtilTests(TestCase):
+    _RUN_IN_TEMP = True
+
+    def test_ensure_importable(self):
+        test_dir = os.path.join(self._work_dir, 'test_dir')
+        # Make sure test data is suitable for the test
+        self.assertNotIn(test_dir, sys.path)
+        util.ensure_importable(test_dir)
+        self.assertEqual(test_dir, sys.path[0])