如何使用PEP 420命名空间包Pytest项目?

How do I Pytest a project using PEP 420 namespace packages?

我正在尝试使用pytest来测试一个较大的项目(~100k loc,1k个文件),并且还有其他几个类似的项目,我最终也希望这样做。这不是标准的python包;它是一个高度定制的系统的一部分,我几乎没有能力更改它,至少在短期内是这样。测试模块与代码集成在一起,而不是在一个单独的目录中,这对我们很重要。配置与这个问题非常相似,我的答案也可能提供有用的背景。

我面临的问题是,项目几乎完全使用PEP420隐式名称空间包;也就是说,在任何包目录中几乎没有__init__.py文件。我还没有看到任何包必须是名称空间包的情况,但是考虑到这个项目与其他也有python代码的项目相结合,这可能会发生(或者已经发生了,我只是没有注意到)。

考虑一个类似以下内容的存储库。(对于它的可运行副本,包括下面描述的测试,从Github克隆0cjs/pytest-impl-ns-pkg)下面的所有测试都假定在project/thing/thing_test.py中。

1
2
3
4
5
repo/
    project/
        util/
            thing.py
            thing_test.py

我对测试配置有足够的控制,可以确保sys.path的设置适当,以便导入测试中的代码,从而正常工作。也就是说,以下测试将通过:

1
2
def test_good_import():
    import project.util.thing

但是,Pytest使用它的常规系统从文件中确定包名称,给出不是我配置的标准包名称,并将我的项目的子目录添加到sys.path中。因此,以下两个测试失败:

1
2
3
4
5
6
7
8
9
10
11
def test_modulename():
    assert 'project.util.thing_test' == __name__
    # Result: AssertionError: assert 'project.util.thing_test' == 'thing_test'

def test_bad_import():
    ''' While we have a `project.util.thing` deep in our hierarchy, we do
        not have a top-level `thing` module, so this import should fail.
    '''
    with raises(ImportError):
        import thing
    # Result: Failed: DID NOT RAISE <class 'ImportError'>

如您所见,虽然thing.py始终可以作为project.util.thing导入,但thing_test.py在pytest之外是project.util.thing_test,但在pytest运行中,project/util添加到sys.path中,该模块名为thing_test

这带来了许多问题:

  • 模块名称空间冲突(例如,project/util/thing_test.pyproject/otherstuff/thing_test.py之间)。
  • 未捕获错误的import语句,因为测试中的代码也在使用这些非生产导入路径。
  • 相对导入在测试代码中可能不起作用,因为模块已在层次结构中"移动"。
  • 总的来说,我很担心在测试中有大量额外的路径添加到sys.path中,这些路径在生产中会缺失,因为我发现这方面有很多可能出错。但是我们把它称为第一个(目前,我猜是默认的)选项。
  • 我想我能做的是告诉Pytest,它应该相对于我提供的特定文件系统路径来确定模块名,而不是根据存在和不存在__init__.py文件来决定要使用的路径。但是,我看不到使用pytest进行此操作的方法。(在Pytest中添加这个并不是不可能的,但在不久的将来也不会发生这种情况,因为我想在提出如何做之前,我需要对Pytest有更深入的了解。)

    第三种选择(在适应了当前的情况并改变了如上所述的pytest之后)是简单地向项目中添加几十个__init__.py文件。然而,虽然在它们中使用extend_path可以(我认为)处理正常Python世界中的名称空间与常规包问题,但我认为它将破坏我们在多个项目中声明的包的异常发布系统。(也就是说,如果另一个项目有一个project.util.other模块,并与我们的项目结合发布,那么他们的project/util/__init__.py和我们的project/util/__init__.py之间的冲突将是一个大问题。)解决这一问题将是一个重大挑战,因为我们必须添加一些方法来声明包含__init__.py的目录。实际上是命名空间包。

    是否有方法改进上述选项?我还有其他的选择吗?


    您面临的问题是,您将测试放在名称空间包内的生产代码旁边。如本文所述,pytest将您的设置识别为独立的测试模块:

    Standalone test modules / conftest.py files

    ...

    pytest will find foo/bar/tests/test_foo.py and realize it is NOT part
    of a package given that there’s no __init__.py file in the same folder. It will then add root/foo/bar/tests to sys.path in order to import test_foo.py as the module test_foo. The same is done with the conftest.py file by adding root/foo to sys.path to import it as conftest.

    因此,解决(至少部分)这一问题的正确方法是调整sys.path并将测试与生产代码分开,例如将测试模块thing_test.py移动到一个单独的目录project/util/tests中。因为您不能这样做,所以您别无选择,只能处理pytest的内部(因为您不能通过钩子覆盖模块导入行为)。这里有一个建议:创建一个带补丁的LocalPath类的repo/conftest.py

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    # repo/conftest.py

    import pathlib
    import py._path.local


    # the original pypkgpath method can't deal with namespace packages,
    # considering only dirs with __init__.py as packages
    pypkgpath_orig = py._path.local.LocalPath.pypkgpath

    # we consider all dirs in repo/ to be namespace packages
    rootdir = pathlib.Path(__file__).parent.resolve()
    namespace_pkg_dirs = [str(d) for d in rootdir.iterdir() if d.is_dir()]

    # patched method
    def pypkgpath(self):
        # call original lookup
        pkgpath = pypkgpath_orig(self)
        if pkgpath is not None:
            return pkgpath
        # original lookup failed, check if we are subdir of a namespace package
        # if yes, return the namespace package we belong to
        for parent in self.parts(reverse=True):
            if str(parent) in namespace_pkg_dirs:
                return parent
        return None

    # apply patch
    py._path.local.LocalPath.pypkgpath = pypkgpath