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

8 投票
3 回答
1038 浏览
提问于 2025-04-18 08:02

当我开发一个只支持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 in (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 个回答

0

你可以把包含 ab 模块的文件夹添加到 PYTHONPATH 里(参考链接:https://docs.python.org/2/using/cmdline.html#envvar-PYTHONPATH)。

另外,正如上面链接所说,如果 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
2

有没有办法让你既能享受蛋糕,又能吃到蛋糕?也就是说,既能作为脚本运行,又能作为包的一部分导入,同时支持Python 2和3?

没有……也许……不过从你想做的事情来看,你把问题搞得比实际复杂多了。我建议你像平常一样创建一个包,这样就可以支持Python 2和3。然后使用一个安装脚本来安装这个包,这样在脚本中导入包时就不需要使用相对路径了。这让你可以在任何地方执行脚本,同时保证你的包兼容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'>)

当然,这意味着如果你以后移动了脚本,或者包被移动了,或者与那个路径相关的东西被移动了,你需要手动更新这些(这就是我认为安装包是更好选择的原因)。

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的变化限制在仅仅这次导入上,所以在基础目录中的任何东西都可能会影响到在任何模块或包中进行的绝对导入。

撰写回答