同级包导入

371

我曾尝试阅读有关兄弟导入的问题,甚至包括包文档,但我仍然没有找到答案。

使用以下结构:

├── LICENSE.md
├── README.md
├── api
│   ├── __init__.py
│   ├── api.py
│   └── api_key.py
├── examples
│   ├── __init__.py
│   ├── example_one.py
│   └── example_two.py
└── tests
│   ├── __init__.py
│   └── test_one.py

如何让 examplestests 目录下的脚本能够从 api 模块导入并且能够从命令行运行?

另外,我想避免为每个文件使用丑陋的 sys.path.insert hack。这在 Python 中肯定有更好的方法,对吗?


15
我建议跳过所有的sys.path黑科技,阅读到目前为止唯一实际的解决方案(在7年之后!)链接 - Aran-Fey
2
顺便说一下,还有另一个好的解决方案:将可执行代码与库代码分开;大多数情况下,包中的脚本一开始就不应该是可执行的。 - Aran-Fey
这非常有帮助,无论是问题还是答案。我只是好奇,在这种情况下,“被接受的答案”为什么不同于获得赏金的答案? - Indominus
1
@Aran-Fey 在这些相对导入错误的问答中,这是一个被低估的提醒。我一直在寻找一个解决方法,但内心深处我知道有一种简单的方式可以设计出这个问题。并不是说这是每个人都适用的解决方案,但它是一个很好的提醒,因为它可能适用于许多人。 - colorlace
19个回答

409

厌倦了sys.path的hack方法吗?

虽然有很多sys.path.append的hack方法可用,但我发现了一种解决手边问题的替代方法。

简介

  • 将代码放入一个文件夹中(例如:packaged_stuff
  • 创建setup.py脚本,并在其中使用setuptools.setup()函数。(参见下面最小化的setup.py示例)
  • 以可编辑状态使用pip install -e <myproject_folder>命令来安装该包
  • 使用from packaged_stuff.modulename import function_name进行导入

设置

起点是您提供的文件结构,放在一个名为myproject的文件夹中。

.
└── myproject
    ├── api
    │   ├── api_key.py
    │   ├── api.py
    │   └── __init__.py
    ├── examples
    │   ├── example_one.py
    │   ├── example_two.py
    │   └── __init__.py
    ├── LICENCE.md
    ├── README.md
    └── tests
        ├── __init__.py
        └── test_one.py

我将把.称为根文件夹,在我的示例中,它位于C:\tmp\test_imports\

api.py

作为测试用例,让我们使用以下路径:./api/api.py

def function_from_api():
    return 'I am the return value from api.api!'

test_one.py

from api.api import function_from_api

def test_function():
    print(function_from_api())

if __name__ == '__main__':
    test_function()

尝试运行 test_one:

PS C:\tmp\test_imports> python .\myproject\tests\test_one.py
Traceback (most recent call last):
  File ".\myproject\tests\test_one.py", line 1, in <module>
    from api.api import function_from_api
ModuleNotFoundError: No module named 'api'

试图使用相对导入也行不通:

使用 from ..api.api import function_from_api 会导致

PS C:\tmp\test_imports> python .\myproject\tests\test_one.py
Traceback (most recent call last):
  File ".\tests\test_one.py", line 1, in <module>
    from ..api.api import function_from_api
ValueError: attempted relative import beyond top-level package

步骤

  1. 在根目录下创建一个 setup.py 文件

setup.py 的内容如下*

from setuptools import setup, find_packages

setup(name='myproject', version='1.0', packages=find_packages())
  1. 使用虚拟环境

如果您熟悉虚拟环境,请激活一个并跳过下一步。 虽然不是绝对必需的,但在长期运行中(当您有多个进行中的项目时),它们将真正帮助您。最基本的步骤是(在根文件夹中运行)

  • 创建虚拟环境
    • python -m venv venv
  • 激活虚拟环境
    • source ./venv/bin/activate (Linux、macOS)或 ./venv/Scripts/activate(Windows)

要了解更多信息,只需谷歌“python 虚拟环境 教程”或类似内容。您可能永远不需要其他命令,除了创建、激活和停用。

一旦您创建并激活了虚拟环境,控制台应该会在括号中显示虚拟环境的名称。

PS C:\tmp\test_imports> python -m venv venv
PS C:\tmp\test_imports> .\venv\Scripts\activate
(venv) PS C:\tmp\test_imports>

你的文件夹树应该看起来像这样**

.
├── myproject
│   ├── api
│   │   ├── api_key.py
│   │   ├── api.py
│   │   └── __init__.py
│   ├── examples
│   │   ├── example_one.py
│   │   ├── example_two.py
│   │   └── __init__.py
│   ├── LICENCE.md
│   ├── README.md
│   └── tests
│       ├── __init__.py
│       └── test_one.py
├── setup.py
└── venv
    ├── Include
    ├── Lib
    ├── pyvenv.cfg
    └── Scripts [87 entries exceeds filelimit, not opening dir]
  1. 使用pip install命令安装你的项目,并加上-e选项,这样安装后的包可以编辑。任何对于.py文件的修改都会自动地包含在安装的包中。

使用pip命令安装顶层包myproject时,需要添加-e选项,这样它将以可编辑状态安装,所有对.py文件所做的更改都将自动包含在已安装的包中。

在根目录下运行

pip install -e .(注意点号,它表示“当前目录”)

您还可以使用pip freeze命令查看已安装的包。

(venv) PS C:\tmp\test_imports> pip install -e .
Obtaining file:///C:/tmp/test_imports
Installing collected packages: myproject
  Running setup.py develop for myproject
Successfully installed myproject
(venv) PS C:\tmp\test_imports> pip freeze
myproject==1.0
  1. myproject.添加到您的导入中

请注意,您只需要将myproject.添加到那些否则无法工作的导入中。那些没有使用setup.pypip install的导入仍然可以正常工作。请参见下面的示例。


测试解决方案

现在,让我们使用上面定义的api.py和下面定义的test_one.py来测试解决方案。

test_one.py

from myproject.api.api import function_from_api

def test_function():
    print(function_from_api())

if __name__ == '__main__':
    test_function()

运行测试

(venv) PS C:\tmp\test_imports> python .\myproject\tests\test_one.py
I am the return value from api.api!

* 查看setuptools文档获取更详细的setup.py示例。

** 实际上,您可以将虚拟环境放在硬盘上的任何位置。


27
感谢您提供详细的帖子。我有一个问题,如果我按照您所说的做了一切,并且执行了 pip freeze 命令,我会得到一行类似这样的内容:-e git+https://username@bitbucket.org/folder/myproject.git@f65466656XXXXX#egg=myproject 请问有任何解决办法吗? - Si Mon
8
为什么相对导入的解决方案不起作用?我相信你,但我想了解Python这个复杂系统的工作原理。 - Jared Nielsen
34
有人遇到关于 ModuleNotFoundError 的问题吗?我按照以下步骤将 'myproject' 安装在虚拟环境中,但当我进入解释器并运行 import myproject 时,出现了 ModuleNotFoundError: No module named 'myproject'pip list installed | grep myproject 显示已经安装了它,目录也正确,并且确认了 pippython 的版本号都是正确的。 - ThoseKind
6
我花了大约两个小时来尝试弄清楚如何让相对导入起作用,而这个答案最终确实做到了一些明智的事情。 - Graham Lea
39
我认为令人恶心的是,我必须来到stackoverflow才能找到如何正确进行相对导入的真正答案。关于这方面的文档确实需要改进,使其更易理解。 - teuber789
显示剩余20条评论

118

七年后

自从我写下面的答案以来,修改sys.path仍然是一个快速而有效的技巧,适用于私人脚本,但已经有了一些改进。

  • 安装包(在虚拟环境中或不在虚拟环境中)将为您提供所需的内容,尽管我建议使用pip进行安装,而不是直接使用setuptools(并使用setup.cfg存储元数据)
  • 使用-m标志运行作为包也可以(但如果要将工作目录转换为可安装包,则会变得有点笨拙)。
  • 特别是对于测试,pytest能够在这种情况下找到api包,并为您处理sys.path的hack

所以这真的取决于你想做什么。在你的情况下,由于似乎你的目标是在某个时候制作一个合适的包,通过pip -e进行安装可能是最好的选择,即使它还不完美。

旧答案

正如其他地方所述,可怕的事实是,您必须进行丑陋的黑客攻击,才能允许从__main__模块导入来自同级模块或父级包的内容。该问题在PEP 366中有详细说明。PEP 3122试图以更合理的方式处理导入,但Guido因为唯一的用例似乎只是运行位于模块目录内的脚本,而他一直认为这是一种反模式,因此拒绝了它。(这里)尽管如此,我经常使用这种模式。
# Ugly hack to allow absolute import from the root folder
# whatever its name is. Please forgive the heresy.
if __name__ == "__main__" and __package__ is None:
    from sys import path
    from os.path import dirname as dir

    path.append(dir(path[0]))
    __package__ = "examples"

import api

这里的path[0]是您正在运行的脚本的父文件夹,dir(path[0])是您的顶级文件夹。

尽管如此,我仍然无法使用相对导入,但它确实允许从顶级(例如您的api的父文件夹)进行绝对导入。


4
如果您在项目目录中使用“-m”表单运行或安装了包,就不必这样做(pip和virtualenv使其变得容易)。 - jfs
4
pytest 是如何找到 API 包的?有趣的是,我发现了这个线程,因为我正在具体使用 pytest 和兄弟包导入时遇到了这个问题。 - JuniorIncanter
1
我有两个问题。1. 对于我来说,您的模式似乎可以在没有__package__ =“examples”的情况下工作。为什么要使用它?2. 在什么情况下__name__ ==“__main__”__package__不是None - actual_panda
@actual_panda 设置 __packages__ 变量有助于确保绝对路径(如 examples.api)有效,如果我没记错的话(但我已经很久没有这样做了)。检查包是否为 None 大多是为了应对异常情况和未来可能的变化。 - Evpok
1
天啊,如果其他编程语言能像Python一样简单就好了。我明白为什么每个人都喜欢这种语言了。顺便说一句,文档也非常出色。我喜欢从非结构化文本中提取返回类型,这是一个很好的改变,不用再看Javadoc和phpdoc了。真是让人心烦。 - matt
显示剩余2条评论

50

这里是另一种选择,我将其插入到tests文件夹中的Python文件顶部:

# Path hack.
import sys, os
sys.path.insert(0, os.path.abspath('..'))

1
+1 真的很简单,而且完美地运行了。你需要将父类添加到导入中(例如api.api,examples.example_two),但我更喜欢这种方式。 - Evan Plaice
15
我认为有必要向像我这样的新手提醒一下,.. 在这里是相对于你执行命令的目录而言,而不是包含测试/示例文件的目录。我是在项目目录中执行的,所以我需要使用 ./ 来代替。希望这能帮助其他人。 - user7851115
@JoshDetwiler,是的,绝对没问题。我之前不知道这个。谢谢。 - doak
6
这个答案很糟糕。修改路径并不是一个好的做法;在Python世界中这种方法被广泛使用,真是太令人震惊了。这个问题的主要目的之一是看看如何在避免这种“黑科技”的情况下进行导入。 - teuber789
2
sys.path.insert(0, os.path.join(os.path.dirname(file), '..'))是将当前文件所在目录的上一级目录添加到系统路径中的代码。@JoshuaDetwiler - vldbnc
@teuber789,我认为在开发阶段使用这种方法来让开发人员快速测试并决定移动代码等方面没有任何问题。对于早期项目或更大的重写作为一种机制,这种方法是完全可接受的。 - Zenahr

43

除非必要,否则不需要也不应该去篡改sys.path。在这种情况下,您可以使用:

import api.api_key # in tests, examples

在项目目录下运行:python -m tests.test_one

如果这些测试是API的单元测试,你应该将tests移动到api中,并运行python -m api.test来运行所有测试(假设存在__main__.py),或者运行python -m api.test.test_one来运行test_one

你也可以从examples中删除__init__.py(它不是Python包),并在安装了api的虚拟环境中运行示例,例如,在虚拟环境中运行pip install -e .会在原地安装api包,如果你有合适的setup.py


@Alex,答案并不假设测试是API测试,除了明确指出“如果它们是API的单元测试”的那一段。 - jfs
不幸的是,这样你只能从根目录运行,而PyCharm仍然找不到文件以使用其美妙的功能。 - mhstnsc
1
@mhstnsc:这不正确。当虚拟环境被激活时,您应该能够从任何地方运行python -m api.test.test_one。如果您无法配置PyCharm来运行测试,请尝试在Stack Overflow上提出新的问题(如果您找不到关于此主题的现有问题)。 - jfs
@jfs 我错过了虚拟环境路径,但我不想使用除shebang行之外的任何东西来从任何目录运行这个东西。这与在PyCharm中运行无关。使用PyCharm的开发人员也会知道他们有完成和跳转功能,我无法使其与任何解决方案一起工作。 - mhstnsc
1
@mhstnsc 在许多情况下,适当的shebang就足够了(将其指向virtualenv Python二进制文件)。任何优秀的Python IDE都应该支持virtualenv。 - jfs

12

我还没有足够的Python语言学理解力来看出在不使用兄弟/相对导入hack的情况下在不相关的项目之间共享代码的预期方法。直到那一天,这是我的解决方案。 对于示例测试..\api导入东西,它会像这样:

import sys.path
import os.path
# Import from sibling directory ..\api
sys.path.append(os.path.dirname(os.path.abspath(__file__)) + "/..")
import api.api
import api.api_key

这样仍然会给您API的父目录,您不需要使用"/.."连接。sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(file))))) - Camilo Sanchez

9

给2023年的读者:如果你对pip install -e不自信:

简而言之:一个脚本(通常是入口点)只能import与其层级相同或更低的内容。

考虑以下这种层次结构,这是Python 3中的相对导入的建议答案:

MyProject
├── src
│   ├── bot
│   │   ├── __init__.py
│   │   ├── main.py
│   │   └── sib1.py
│   └── mod
│       ├── __init__.py
│       └── module1.py
└── main.py

要从起始点以简单命令 python main.py 运行我们的程序,我们在这里使用绝对导入(没有前导点)在 main.py 中:

from src.bot import main


if __name__ == '__main__':
    main.magic_tricks()

文件 bot/main.py 的内容利用了显式相对导入来展示我们正在导入的内容,看起来像这样:

from .sib1 import my_drink                # Both are explicit-relative-imports.
from ..mod.module1 import relative_magic

def magic_tricks():
    # Using sub-magic
    relative_magic(in=["newbie", "pain"], advice="cheer_up")
    
    my_drink()
    # Do your work
    ...

这些是推理的原因:
我们不想在运行Python程序时给"OK,所以这是一个模块"带来麻烦,真心的。 因此,我们在入口点main.py中使用绝对导入,这样我们只需简单地运行python main.py就可以运行我们的程序。 在幕后,Python将使用sys.path为我们解析包,但这也意味着我们要导入的包可能会被sys.path中路径的任何其他同名包替代,例如尝试import test。 为了避免这些冲突,我们使用显式相对导入。 from ..mod语法非常清楚地表明"我们正在导入我们自己的本地包"。 但缺点是当您想将模块作为脚本运行时,您需要再次考虑"OK,所以这是一个模块"。 最后,from ..mod部分意味着它将向上一级到MyProject/src。

结论

  1. 将您的 main.py 脚本放置在所有包的根目录 MyProject/src 旁边,并在 python main.py 中使用绝对导入来导入任何内容。没有人会创建名为 src 的包。
  2. 这些显式相对导入将正常工作。
  3. 要运行一个模块,请使用 python -m ...

附录:关于如何将 src/ 下的任何文件作为脚本运行的更多信息?

那么您应该使用语法 python -m 并查看我的其他帖子:ModuleNotFoundError: No module named 'sib1'


这对我实际有效。我认为如果没有这个"src"目录/包,子包就没有公共的根包。 - stoper
@stoper 感谢你的反馈。很自豪地说,没有人能在这里超越我的解释。我阅读了很多答案并自己总结出来的。 - VimNing

6

对于兄弟包导入,您可以使用[sys.path][2]模块的insertappend方法:

if __name__ == '__main__' and if __package__ is None:
    import sys
    from os import path
    sys.path.append( path.dirname( path.dirname( path.abspath(__file__) ) ) )
    import api

如果您按照以下方式启动脚本,它将起作用:

python examples/example_one.py
python tests/test_one.py

另一方面,您还可以使用相对导入:
if __name__ == '__main__' and if __package__ is not None:
    import ..api.api

在这种情况下,您需要使用'-m'参数来启动脚本(请注意,在这种情况下,您不必给出'.py'扩展名):
python -m packageName.examples.example_one
python -m packageName.tests.test_one

当然,你可以混合这两种方法,这样你的脚本不管怎么被调用都可以工作:
if __name__ == '__main__':
    if __package__ is None:
        import sys
        from os import path
        sys.path.append( path.dirname( path.dirname( path.abspath(__file__) ) ) )
        import api
    else:
        import ..api.api

我之前使用的是Click框架,该框架没有__file__全局变量,因此我不得不使用以下代码:sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(sys.argv[0]))))。但现在它可以在任何目录下工作了。 - GammaGames
1
这个问题没有好的答案。这表明Python的开发者们似乎生活在某种幻想泡泡中,其中相对导入几乎是不可能的。 - C.J.

3

TLDR

这种方法不需要setuptools、路径hack、额外的命令行参数或在项目的每个文件中指定包的顶层。

只需在你要调用的代码所在的父目录中创建一个脚本作为__main__,并从那里运行所有内容。如需进一步解释,请继续阅读。

解释

这可以在不使用hack新路径、额外命令行参数或添加代码来识别兄弟程序的情况下完成。

我认为之前提到的原因是被调用的程序将其__name__设置为__main__。当发生这种情况时,被调用的脚本接受自己位于包的顶层,并拒绝识别兄弟目录中的脚本。

然而,在目录的顶层以下的所有内容仍将识别顶层以下的任何其他内容。这意味着你需要做的唯一一件事就是从它们的父目录中的一个脚本中调用兄弟目录中的文件,以便让它们相互识别/利用。

概念证明 在具有以下结构的目录中:

.
|__Main.py
|
|__Siblings
   |
   |___sib1
   |   |
   |   |__call.py
   |
   |___sib2
       |
       |__callsib.py

Main.py 包含以下代码:

import sib1.call as call


def main():
    call.Call()


if __name__ == '__main__':
    main()

sib1/call.py 包含如下内容:

import sib2.callsib as callsib


def Call():
    callsib.CallSib()


if __name__ == '__main__':
    Call()

并且sib2/callsib.py包含:

def CallSib():
    print("Got Called")

if __name__ == '__main__':
    CallSib()

如果您复制此示例,您会注意到调用 Main.py 会导致 "Got Called" 被打印,正如在 sib2/callsib.py 中定义的那样,即使是通过 sib1/call.py 调用 sib2/callsib.py。然而,如果直接调用 sib1/call.py(在适当更改导入后),它会抛出异常。即使在其父目录中的脚本调用时工作正常,但如果它认为自己处于包的顶层,它也无法工作。

OP特别要求:“如何使examplestests目录中的脚本能够从api模块导入并且可以从命令行运行?”这个答案不能适用于OP的文件夹结构。 - Niko Pasanen
你不需要main.py文件;而是执行命令python main.py调用sib1.call,你可以执行python -m sib1.call - Ersel Er
我遇到了一个问题,其中出现了 ImportError: No module named sib1.call - Evan Gertis

2

您需要查看相关代码中的导入语句。如果examples/example_one.py使用以下导入语句:

import api.api

如果期望程序在系统中找到项目的根目录,则需要将其添加到系统路径中。

最简单的支持方法(避免使用任何hack技巧)是从项目的顶层目录运行示例,像这样:

PYTHONPATH=$PYTHONPATH:. python examples/example_one.py 

使用 Python 2.7.1 我得到以下输出:$ python examples/example.py Traceback(最近的调用最后): File“examples/example.py”,第3行,在<module>中 从api.api导入API ImportError:没有名为api.api的模块。我使用import api.api同样得到相同的错误输出。 - zachwill
更新了我的回答...你确实需要将当前目录添加到导入路径中,没有其他方法。 - AJ.

1
我制作了一个示例项目来展示我如何处理这个问题,正如上面提到的那样,这实际上是另一种sys.path hack。Python兄弟导入示例,它依赖于以下代码: if __name__ == '__main__': import os import sys sys.path.append(os.getcwd()) 只要您的工作目录保持在Python项目的根目录下,这似乎非常有效。

1
只有当您从脚本的父目录运行时,才能使此功能正常工作。 - Evpok

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