在Python 2和3中,如何导入既适用于包内又适用于包外的结构?

8
当我开发一个纯Python 2的包时,我可以使用简单的import b语法来导入相对路径,而不必关心导入文件是否在包内。这样做的好处是,我可以通过执行文件来运行任何文件中的if __name__ == "__main__":块,并且所有的导入都能正常工作。
添加Python 3支持后,我必须转移到新的相对导入语法,这也被2.7支持:from . import b。然而,这种语法仅在包内起作用。直接执行文件将不再起作用:
Traceback (most recent call last):
  File "./a.py", line 2, in <module>
    from . import b
ValueError: Attempted relative import in non-package

一个解决方法是通过从上级目录导入它作为模块来调用文件:
python -m foo.a

然而,这会对工作目录提出要求,这将阻止您将输出导管到其他同样关注工作目录的程序中。有没有办法两全其美?即在Python 2和3中支持脚本运行和作为包的一部分导入,同时在两者中正常工作?

示例包结构:

foo/
foo/__init__.py
foo/a.py (imports b)
foo/b.py (imports c)
foo/c.py

我希望以下两种情况都能适用于x在(a, b, c)范围内:

import foo.x (in some file when foo/ is in path)

python[23] path/to/foo/x.py

下面的评论提到根据PEP 366设置__package__,但是"如果脚本被移动到不同的包或子包中,则需要手动更新样板文件。" 更新:我尝试让PEP 366解决方案起作用,但无法理解。它说:

需要额外的代码来操作sys.path,以便在顶级包不可导入的情况下使直接执行工作。

这是从未导入的包中执行文件时的情况。那么这个额外的代码会是什么样子?

3
如果你的包名被内置模块所遮盖,那么这个包就很难使用了,是吧?无论如何,设置__package__可能会有帮助。 - user2357112
@user2357112,一个子包可以有与内置模块冲突的名称,但仍然可以完全使用,尽管我承认这可能不是一个好主意。无论如何,“其他程序关心WD”是我遇到的一个实际问题。我会看一下__package__ - otus
我不确定这是否有所帮助,但是我在这个Python应用程序模板中至少部分地涵盖了这个主题:https://github.com/jgehrcke/python-cmdline-bootstrap - Dr. Jan-Philip Gehrcke
@Jan-PhilipGehrcke,谢谢,但那并不是一个解决方案。为每个文件都有一个单独的运行文件会很麻烦。而且当你安装包时它们会纠缠在一起。 - otus
我相信__package__是你正在寻找的解决方案,但我不理解你关于尝试它的编辑。你不仅希望import foo.x能够工作,而且即使你将foo重命名为其他名称,它也可以自动工作?如果你更改包名称,显然你必须更改导入。一般来说,导入只能与sys.path上的模块/包一起使用。如果你想要伪造它,以便导入不在sys.path上的内容,你需要在程序内修改sys.path,以便脚本将其包目录添加到路径中。 - BrenBarn
显示剩余2条评论
3个回答

2
有没有一种方法既可以作为脚本运行,又可以作为包的一部分导入,在Python 2和3中都能正常工作?......也许吧。但从我所了解的你想做的事情来看,你正在把一个问题变得比它应该的更加复杂。我建议像正常情况下一样创建一个包,在其中支持Python 2和3。然后使用setup脚本进行安装,并在脚本中导入该包而无需使用相对路径。这样你就可以在任何地方自由执行脚本,同时保证包的Python 2和3兼容性。
尽管如此,我仍然坚持我上面的原始声明,认为你正在把这个问题变得比它实际需要的更加复杂,或者你没有告诉我们为什么必须这样做的所有信息。无论如何,如果你遵循PEP 366的规定,这应该是可行的。在包含脚本的模块中(即包含if __name__ == "__main__":的文件),然后在文件的开头(或在你的主要if __name__ == "__main__":之前)添加以下行:
if __name__ == "__main__" and __package__ == None:
        __package__ == "expected.package.name"
        sys.path.append(<path to root package 'expected'>)

当然,这意味着如果您将脚本移动或包被移动,或与该路径相关的任何内容被移动,您将需要手动更新这些内容(这就是为什么我仍然认为安装是更好的选择)。

我已经在这样做了(文件可安装并使用模块语法),但我主要的使用情况是在安装之前运行测试。即使在安装后,能够将脚本既视为包的一部分又视为可执行文件也很有用。例如,您可以通过简单的符号链接将其暴露在命令行上:/usr/local/bin/foo -> /usr/local/lib/python2.7/dist-packages/bar/foo.py。(PEP还说明了添加该功能的原因。) - otus
你的意思是它没有被实现吗?它的状态是最终版,是Python 2.6+的一部分。 - otus
@otus,你想要的功能还没有被实现。如果你愿意将包的根目录添加到sys.path中,你可以遵循PEP 366的指南,这本质上与我上面提到的安装包是一样的。 - James Mertz
我尝试按照PEP建议将包的根目录添加到sys.path中,但无法使其正常工作,出现了ImportError。这就是为什么我更新了问题,询问那段代码应该是什么样子的原因。如果它能正常工作,那么与安装不同,因为路径操作发生在我运行文件时的Python中,而不是我必须在shell中执行它来模拟安装。 - otus
为什么不使用'virtualenv'呢?我已经更新了答案以匹配我认为你想要做的事情,但我不认为这是你真正想要的答案。 - James Mertz
显示剩余6条评论

0

你可以将 ab 模块所在的目录添加到 PYTHONPATH 中 (参考:https://docs.python.org/2/using/cmdline.html#envvar-PYTHONPATH)。

此外,如上链接所述,如果 a 是主模块,则包含模块 a 的目录会自动添加到 PYTHONPATH 中。例如,如果你在以下代码中有 /test/a.py/test/b.py 文件:

/test/a.py

if __name__ == '__main__':
    import sys
    print(sys.path)

    import b
    print('this is a')

/test/b.py

print('this is b')

你可以这样执行a

$ cd /test/
$ python3 a.py

你将会得到一个输出:

['', '/usr/lib/python34.zip', '/usr/lib/python3.4', '/usr/lib/python3.4/plat-linux', '/usr/lib/python3.4/lib-dynload', '/usr/lib/python3.4/site-packages']
this is b
this is a

另外,如果您执行:

$ python3 /test/a.py

你将会得到输出:

['/test', '/usr/lib/python34.zip', '/usr/lib/python3.4', '/usr/lib/python3.4/plat-linux', '/usr/lib/python3.4/lib-dynload', '/usr/lib/python3.4/site-packages']
this is b
this is a

ab都是foo包的一部分。您通过删除点号破坏了在Python 3中的import foo.a。另外,如果b需要导入c,当执行a时它不是"__main__",那么我该如何应用您使用的方法? - otus
我已经在问题中添加了包结构,这样你或许就能明白我的意思了。 - otus
如果你有一个名为foo的包,那么你应该始终导入并执行foo.x。如果没有这个包,那么就导入并执行x。正如你所要求的,在上面的例子中,如果你在b中导入c,它将正常工作,因为x(a、b、c)所在的目录已经在PYTHONPATH中。另外,如果需要的话,你可以将'foo/'和它的父目录都添加到PYTHONPATH中。 - Debanshu Kundu
如果你有一个名为foo的包,那么你应该始终导入并执行foo.x。整个问题(见标题)在于不必这样做,而是也能像python a.py一样直接执行文件。关于原理,请参见问题和链接的PEP。 - otus
在这种情况下,您可以这样做:“另外,如果需要,您可以将'foo/'目录及其父目录都添加到PYTHONPATH中”。 - Debanshu Kundu
我不明白这样做会有何帮助,至少不会让包中的所有模块都覆盖其他地方的对应模块。也就是说,如果包含一个名为 "bar" 的模块,它会覆盖同名的顶层包(或者根据顺序被覆盖)。命名空间将会被破坏。 - otus

0

这里是一个基于KronoS的答案和评论的解决方案,它允许在路径或包名称相同的情况下使用相同的模板:

if __name__ == "__main__" and __package__ == None:
    import importlib
    import os.path
    import sys
    def _gen_path():
        head, tail = os.path.split(os.path.realpath(__file__))
        while head:
            if not os.path.isfile(os.path.join(head, '__init__.py')):
                yield head
                return
            head, tail = os.path.split(head)
            yield tail
    def _load_package():
        path = list(_gen_path())
        syspath = sys.path[:]
        sys.path[:0] = [path.pop()]
        package = '.'.join(reversed(path))
        importlib.import_module(package)
        sys.path = syspath
        return package
    __package__ = _load_package()

它会一直向上遍历文件路径,直到找到标记包的__init__.py文件,然后导入模块的父级包,并正确设置__package__。在此之后,像from ..bar import baz这样的相对导入就可以正常工作了。


很遗憾,将这些函数放在自己的模块中会让你回到原点。此外,似乎没有一种Python 2/3可移植的方法来限制sys.path更改仅限于该导入,因此基目录中的任何内容都可能会遮盖父包与任何导入的模块或包中的绝对导入。


看到你在这里的回答,只是强调了我的原始答案。我强烈建议使用虚拟环境(virtualenv)并在其中测试您的代码。这不是很符合Python风格,并且在我看来最终会导致更多的问题。 - James Mertz
1
@KronoS,我同意这不是很Pythonic,发帖之前我犹豫了。在我的理想世界里,我可以在包外部进行相对导入(为什么不呢?)。然而,virtualenv 对于我特定的用例(在安装之前运行测试,可能会使用 virtualenv)并不是一个解决方案。 - otus
我想把这个讨论移到 chat 上,以免堵塞评论区。也许是因为我没有完全理解你的情况,所以我不明白为什么使用 virtualenv 不可行。 - James Mertz
@KronoS,基本上,setup.py在安装包时调用另一个程序(需要工作目录的要求)来对模块运行一些测试。 - otus

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接