关于python:兄弟包导入

Sibling package imports

我试着通读有关兄弟姐妹进口的问题,甚至打包文档,但我还没有找到答案。

具有以下结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
├── 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黑客攻击。当然这可以在python中完成,对吗?


厌倦了系统路径黑客?

有很多可用的sys.path.append黑客,但我找到了解决手头问题的另一种方法:设置工具。我不确定是否有边缘案例不能很好地解决这个问题。下面是用python3.6.5(anaconda,conda4.5.1),windows10机器测试的。

安装程序

起始点是您提供的文件结构,包装在名为myproject的文件夹中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
.
└── 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\

Ap.Py

作为一个测试用例,让我们使用下面的/api/api.py

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

TestyOn.Py

1
2
3
4
5
6
7
from api.api import function_from_api

def test_function():
    print(function_from_api())

if __name__ == '__main__':
    test_function()

试着运行测试一:

1
2
3
4
5
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会导致

1
2
3
4
5
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的内容是*

1
2
3
from setuptools import setup, find_packages

setup(name='myproject', version='1.0', packages=find_packages())

2)使用虚拟环境

如果您熟悉虚拟环境,请激活其中一个,然后跳到下一步。虚拟环境的使用并不是绝对必要的,但从长远来看,它们确实会帮助您(当您有一个以上的项目正在进行时)。最基本的步骤是(在根文件夹中运行)

  • 创建虚拟环境
    • python -m venv venv
  • 激活虚拟环境
    • source ./venv/bin/activate(linux,macos)或./venv/Scripts/activate(win)

要了解更多信息,只需谷歌出"python virtual env tutorial"或类似内容。除了创建、激活和停用之外,您可能永远不需要任何其他命令。

创建并激活虚拟环境后,控制台应在括号中给出虚拟环境的名称。

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

您的文件夹树应该像这样**

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
.
├── 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]

3)pip在可编辑状态下安装项目

使用pip安装顶级软件包myproject。技巧是在安装时使用-e标志。这样,它将以可编辑状态安装,并且对.py文件所做的所有编辑都将自动包含在已安装的包中。

在根目录中,运行

pip install -e .(注意点,它代表"当前目录")。

您还可以看到它是通过使用pip freeze安装的。

1
2
3
4
5
6
7
(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

4)在进口产品中增加myproject.

请注意,您只需将myproject.添加到不起作用的进口中。没有setup.pypip install的进口产品仍然可以工作。请参见下面的示例。

测试溶液

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

TestyOn.Py

1
2
3
4
5
6
7
from myproject.api.api import function_from_api

def test_function():
    print(function_from_api())

if __name__ == '__main__':
    test_function()

运行测试

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

*有关更详细的setup.py示例,请参阅setuptools文档。

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


七年后

由于我在下面写了答案,修改sys.path仍然是一个快速而肮脏的技巧,对私有脚本很有效,但是有一些改进

  • 安装软件包(无论是否在virtualenv中)都可以满足您的需要,不过我建议使用pip来完成,而不是直接使用安装工具(以及使用setup.cfg来存储元数据)。
  • 使用-m标志并作为包运行也可以工作(但如果您想将工作目录转换为可安装的包,结果会有点尴尬)。
  • 具体来说,对于测试,pytest能够在这种情况下找到API包,并为您处理sys.path黑客程序。

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

旧答案

正如其他地方已经提到的,可怕的事实是,您必须进行丑陋的黑客攻击,以允许从兄弟模块或父包中从__main__模块导入数据。该问题在PEP 366中有详细说明。PEP 3122试图以更合理的方式处理进口,但Guido拒绝了这一说法。

The only use case seems to be running scripts that happen
to be living inside a module's directory, which I've always seen as an
antipattern.

(这里)

不过,我经常使用这种模式

1
2
3
4
5
6
7
8
9
10
# 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的父文件夹)。


下面是我在tests文件夹中的python文件顶部插入的另一个选项:

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


你不需要也不应该入侵sys.path,除非它是必要的,在这种情况下它不是。用途:

1
import api.api_key # in tests, examples

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

您可能应该将tests(如果它们是API的unittests)移动到api中,然后运行python -m api.test来运行所有测试(假设有__main__.py),或者运行python -m api.test.test_one来运行test_one

您还可以从examples中删除__init__.py(它不是python包),并在安装了api的virtualenv中运行示例,例如,在virtualenv中,pip install -e .将安装在适当的api包中。


我还没有理解在没有兄弟/相关的导入黑客的情况下,如何在不相关的项目之间共享代码的预期方式所必需的pythology。直到那天,这是我的解决办法。对于examplestests..\api进口的货物,应该是:

1
2
3
4
5
6
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


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

1
2
3
4
5
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

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

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

另一方面,您也可以使用相对导入:

1
2
if __name__ == '__main__' and if __package__ is not None:
    import ..api.api

在这种情况下,您必须使用"-m"参数启动脚本(注意,在这种情况下,您不能提供".py"扩展名):

1
2
python -m packageName.examples.example_one
python -m packageName.tests.test_one

当然,您可以混合使用这两种方法,这样无论脚本如何调用,它都可以工作:

1
2
3
4
5
6
7
8
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


以防Eclipse上使用pydev的人在这里结束:您可以使用project->properties将兄弟姐妹的父路径(以及调用模块的父路径)添加为外部库文件夹,并在左侧菜单pydev pythonpath下设置外部库。然后你可以从你的兄弟姐妹那里进口,如from sibling import some_class


您需要查看导入语句是如何用相关代码编写的。如果examples/example_one.py使用以下进口声明:

1
import api.api

…然后它期望项目的根目录在系统路径中。

最简单的方法是在没有任何黑客的情况下(如您所说)从顶层目录运行示例,如下所示:

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


TLDR

此方法不需要安装工具、路径黑客、其他命令行参数,也不需要在项目的每个文件中指定包的顶级。

只需在父目录中编写一个脚本,无论您所调用的是什么,都将成为您的__main__,然后从中运行所有内容。如需进一步解释,请继续阅读。

解释

这可以在不破坏新路径、额外的命令行参数或向每个程序添加代码以识别其兄弟程序的情况下完成。

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
.
|__Main.py
|
|__Siblings
   |
   |___sib1
   |   |
   |   |__call.py
   |
   |___sib2
       |
       |__callsib.py

Main.py包含以下代码:

1
2
3
4
5
6
7
8
9
import sib1.call as call


def main():
    call.Call()


if __name__ == '__main__':
    main()

sib1/call.py包含:

1
2
3
4
5
6
7
8
9
import sib2.callsib as callsib


def Call():
    callsib.CallSib()


if __name__ == '__main__':
    Call()

sib2/callsib.py包含:

1
2
3
4
5
def CallSib():
    print("Got Called")

if __name__ == '__main__':
    CallSib()

如果复制此示例,您会注意到调用Main.py将导致"got called"按sib2/callsib.py中的定义打印,即使sib2/callsib.py是通过sib1/call.py调用的。但是,如果直接调用sib1/call.py(在对进口进行适当的更改之后),它就会抛出一个异常。即使它在父目录中的脚本调用时工作,如果它相信自己在包的顶层,它也不会工作。


首先,您应该避免使用与模块本身同名的文件。它可能会破坏其他进口产品。

导入文件时,首先解释器检查当前目录,然后搜索全局目录。

examplestests内,您可以拨打:

1
from ..api import api