Python 包结构非常简单的 Python:项目结构和导入
设置存储库
PEP 8 和命名
包和模块
导入工作原理
进口注意事项
在您的项目中导入
__main__.py
总结
选项 1:原生脚本
选项 2:Python 脚本
喜欢这些文章吗?那就买本书吧! Jason C. McDonald 的《Dead Simple Python》现已由 No Starch Press 出版。
教程最糟糕的地方总是过于简单,不是吗?你很少会找到一个包含多个文件的教程,更别提包含多个目录的教程了。
我发现,构建 Python 项目是 Python 教学中最常被忽视的部分之一。更糟糕的是,许多开发人员犯了错,在一堆常见错误中摸索,最终找到一个至少能用得上的方案。
好消息是:您不必成为他们中的一员!
在“极简 Python”系列的这一期中,我们将探索import
语句、模块、包,以及如何轻松整合所有内容。我们甚至会涉及 VCS、PEP 和 Python 之禅。系好安全带!
设置存储库
在深入探讨实际项目结构之前,我们先来聊聊它如何与我们的版本控制系统 (VCS) 相契合……首先,你需要一个 VCS!原因如下……
- 追踪你所做的每一个改变,
- 弄清楚你什么时候弄坏了什么东西,
- 能够查看代码的旧版本,
- 备份你的代码,以及
- 与他人合作。
你有很多选择。Git是最显而易见的选择,尤其是在你不知道还有什么好用的时候。你可以在 GitHub、GitLab、Bitbucket 或 Gitote 等平台上免费托管你的 Git 仓库。如果你不想使用 Git,还有许多其他选择,包括 Mercurial、Bazaar 和 Subversion(不过,如果你使用 Subversion,你的同行可能会觉得你过时了)。
我将默认您在本指南的其余部分使用 Git,因为这是我专门使用的。
创建仓库并将本地副本克隆到计算机后,即可开始设置项目。至少,您需要创建以下内容:
README.md
:您的项目及其目标的描述。LICENSE.md
:如果您的项目是开源的,则需要提供其许可证。(有关如何选择许可证的更多信息,请参阅opensource.org。).gitignore
:一个特殊文件,用于告诉 Git 哪些文件和目录需要忽略。(如果您使用的是其他版本控制系统,则此文件的名称可能有所不同。请自行查找。)- 包含您的项目名称的目录。
没错……我们的 Python 代码文件实际上应该放在一个单独的子目录中!这一点非常重要,因为我们的仓库根目录会堆满构建文件、打包脚本、虚拟环境以及各种其他不属于源代码的内容,变得非常杂乱。
仅为了举例,我们将我们的虚构项目称为awesomething
。
PEP 8 和命名
Python 的风格主要由一组名为“Python 增强提案”(简称PEP)的文档所规范。当然,并非所有 PEP 都会被采纳——这也是它们被称为“提案”的原因——但有些 PEP 确实被采纳了。您可以在 Python 官方网站上浏览 PEP 主索引。该索引的正式名称是PEP 0。
目前,我们主要关注的是PEP 8,它最初由 Python 语言创始人 Guido van Rossum 于 2001 年撰写。它是一份官方文档,概述了所有 Python 开发人员通常应遵循的编码风格。请把它放在你的枕头下!学习它,遵循它,并鼓励其他人也这样做。
(附注:PEP 8 指出样式规则总有例外。它是一个指南,而不是命令。)
现在,我们主要关注题为“包和模块名称”的部分......
模块名称应简短,且全部小写。如果下划线可以提高可读性,模块名称中可以使用下划线。Python 包名称也应简短,且全部小写,但不建议使用下划线。
我们稍后会了解模块和包到底是什么,但是现在,请理解模块是通过文件名命名的,而包是通过其目录名命名的。
换句话说,文件名应该全部小写,如果为了提高可读性,可以添加下划线。同样,目录名也应该全部小写,如果可以避免,可以省略下划线。换句话说……
- 这样做:
awesomething/data/load_settings.py
- 不是这个:
awesomething/Data/LoadSettings.py
我知道,我知道,用这么长的方式来表达观点,但至少我给你打了个招呼。(你好?这个东西开着吗?)
包和模块
这会让人感觉有点虎头蛇尾,但以下是承诺的定义:
任何 Python ( .py
) 文件都是一个模块,目录中的一堆模块就是一个包。
嗯……差不多了。要把一个目录变成包,你还需要做一件事,那就是把一个叫 的文件粘贴__init__.py
到里面。实际上,你不需要往里面放任何东西。它只要在那里就行了。
您还可以做其他很酷的事情__init__.py
,但这超出了本指南的范围,因此请阅读文档以了解更多信息。
如果你真的忘记了__init__.py
在包中添加命名空间,它将会做出比失败更奇怪的事情,因为这会让它成为一个隐式命名空间包。你可以用这种特殊类型的包做一些巧妙的事情,但我在这里就不展开了。像往常一样,你可以通过阅读文档了解更多信息:PEP 420:隐式命名空间包。
所以,如果我们看一下我们的项目结构,awesomething
它实际上是一个包,它可以包含其他包。因此,我们可以将顶层包awesomething
称为,并将其下的所有包称为子包。这在我们导入内容时非常重要。
让我们看一下我的真实世界项目的快照,omission
以了解我们如何构建东西......
omission-git
├── LICENSE.md
├── omission
│ ├── app.py
│ ├── common
│ │ ├── classproperty.py
│ │ ├── constants.py
│ │ ├── game_enums.py
│ │ └── __init__.py
│ ├── data
│ │ ├── data_loader.py
│ │ ├── game_round_settings.py
│ │ ├── __init__.py
│ │ ├── scoreboard.py
│ │ └── settings.py
│ ├── game
│ │ ├── content_loader.py
│ │ ├── game_item.py
│ │ ├── game_round.py
│ │ ├── __init__.py
│ │ └── timer.py
│ ├── __init__.py
│ ├── __main__.py
│ ├── resources
│ └── tests
│ ├── __init__.py
│ ├── test_game_item.py
│ ├── test_game_round_settings.py
│ ├── test_scoreboard.py
│ ├── test_settings.py
│ ├── test_test.py
│ └── test_timer.py
├── pylintrc
├── README.md
└── .gitignore
(如果您好奇的话,我使用 UNIX 程序tree
制作了上面的小图。)
您会看到我有一个名为的顶级包omission
,它有四个子包:common
、、和。我还有目录,但其中仅包含游戏音频、图像等(为简洁起见,此处省略)。data
不是一个包,因为它不包含。game
tests
resources
resources
__init__.py
我的顶级包中还有另一个特殊文件:__main__.py
。当我们通过 直接执行顶级包时,会运行这个文件。我们稍后python -m omission
会讨论它里面的内容。__main__.py
导入工作原理
如果你之前写过任何有意义的 Python 代码,你肯定对这个import
语句很熟悉。例如……
import re
了解这一点很有帮助,当我们导入一个模块时,我们实际上正在运行它。这意味着import
模块中的任何语句也都在运行。
例如,re.py
它本身就有几个 import 语句,当我们说 时就会执行import re
。这并不意味着它们对我们导入re
的 的文件可用,但这确实意味着这些文件必须存在。如果(由于某些不太可能的原因)enum.py
在你的环境中被删除了,而你又运行import re
,它就会失败并出现错误……
回溯(最近一次调用最后一次):
文件“weird.py”,第 1 行,在
导入 re
文件“re.py”,第 122 行,在
导入 enum
ModuleNotFoundError:没有名为“enum”的模块
读到这里,你可能会感到有些困惑。有人问我,为什么re
找不到外部模块(本例中为 )。还有人想知道,enum
既然他们并没有在代码中直接请求导入内部模块(此处),为什么还要导入它呢?答案很简单:我们导入了re
,而它又导入了enum
。
当然,上述场景是虚构的:import enum
和import re
在正常情况下永远不会失败,因为这两个模块都是 Python 核心库的一部分。这只是一个比较简单的示例。;)
进口注意事项
实际上,导入的方法有很多种,但大多数方法很少使用,甚至根本不使用。
对于下面的所有示例,我们假设有一个名为的文件smart_door.py
:
# smart_door.py
def close():
print("Ahhhhhhhhhhhh.")
def open():
print("Thank you for making a simple door very happy.")
仅举例来说,我们将在 Python 交互式 shell 中从与 相同的目录运行本节中的其余代码smart_door.py
。
如果我们要运行该函数open()
,必须先导入该模块smart_door
。最简单的方法是……
import smart_door
smart_door.open()
smart_door.close()
我们实际上会说那smart_door
是和的命名空间。Python 开发人员非常喜欢命名空间,因为它们可以清楚地显示函数和其他东西的来源。open()
close()
(顺便说一句,不要将namespace与implicit namespace package混淆。它们是两个不同的东西。)
Python 之禅(也称为PEP 20)定义了 Python 语言背后的哲学。其中最后一行有一句话阐述了这一点:
命名空间是一个非常棒的主意——让我们多做一些这样的事情!
然而,到了一定程度,命名空间可能会变得很麻烦,尤其是在嵌套包的情况下。foo.bar.baz.whatever.doThing()
这简直太丑了。还好,我们确实有办法避免每次调用函数时都必须使用命名空间。
如果我们希望能够使用该open()
函数而不必在其前面不断加上模块名称,我们可以这样做......
from smart_door import open
open()
不过请注意,在最后一种情况下, close()
norsmart_door.close()
函数不起作用,因为我们没有直接导入该函数。要使用它,我们必须将代码改成这样……
from smart_door import open, close
open()
close()
在之前那个可怕的嵌套包噩梦中,我们现在可以说from foo.bar.baz.whatever import doThing
,然后doThing()
直接使用。或者,如果我们想要一点点命名空间,我们可以说from foo.bar.baz import whatever
,然后说whatever.doThing()
。
该import
系统非常灵活。
不过,不久之后,你可能会发现自己说:“但是我的模块中有数百个函数,我想全部使用它们!”很多开发人员正是在这个时候偏离了轨道,这样做……
from smart_door import *
这太糟糕了!简而言之,它直接导入了模块里的所有内容,这本身就有问题。想象一下下面的代码……
from smart_door import *
from gzip import *
open()
你认为会发生什么?答案是,gzip.open()
将是被调用的函数,因为这是open()
我们代码中导入的最后一个版本,因此也是定义的。smart_door.open()
已经被遮蔽了——我们不能将它调用为open()
,这意味着我们实际上根本不能调用它。
当然,由于我们通常不知道,或者至少不记得每个导入的模块中的每个函数、类和变量,因此我们很容易陷入一大堆混乱。
Python 之禅也解决了这种情况......
明确优于隐含。
你永远不需要猜测函数或变量的来源。文件中的某个地方应该有明确说明其来源的代码。前两个场景就证明了这一点。
我还应该提一下,之前foo.bar.baz.whatever.doThing()
提到的情况是 Python 开发者们最不愿意看到的。同样出自《Python 之禅》 ……
扁平比嵌套更好。
一些包的嵌套是可以的,但是当你的项目开始看起来像一堆精致的俄罗斯套娃时,你就做错了。将模块组织到包中,但要保持合理的简洁。
在您的项目中导入
我们之前创建的项目文件结构即将派上用场。回想一下我的omission
项目……
omission-git
├── LICENSE.md
├── omission
│ ├── app.py
│ ├── common
│ │ ├── classproperty.py
│ │ ├── constants.py
│ │ ├── game_enums.py
│ │ └── __init__.py
│ ├── data
│ │ ├── data_loader.py
│ │ ├── game_round_settings.py
│ │ ├── __init__.py
│ │ ├── scoreboard.py
│ │ └── settings.py
│ ├── game
│ │ ├── content_loader.py
│ │ ├── game_item.py
│ │ ├── game_round.py
│ │ ├── __init__.py
│ │ └── timer.py
│ ├── __init__.py
│ ├── __main__.py
│ ├── resources
│ └── tests
│ ├── __init__.py
│ ├── test_game_item.py
│ ├── test_game_round_settings.py
│ ├── test_scoreboard.py
│ ├── test_settings.py
│ ├── test_test.py
│ └── test_timer.py
├── pylintrc
├── README.md
└── .gitignore
在我的game_round_settings
模块(由 定义)中omission/data/game_round_settings.py
,我想使用我的GameMode
类。该类是在 中定义的omission/common/game_enums.py
。我该如何获取它?
因为我将其定义omission
为一个包,并将模块组织到子包中,所以这实际上非常简单。在 中game_round_settings.py
,我说……
from omission.common.game_enums import GameMode
这称为绝对导入。它从顶级包开始,omission
然后向下进入common
包,在那里查找game_enums.py
。
有些开发者向我咨询类似这样的 import 语句from common.game_enums import GameMode
,并想知道为什么它不起作用。简而言之,data
包(所在的game_round_settings.py
包)不知道它的兄弟包。
然而,它确实知道它的父级。因此,Python 提供了一个名为“相对导入”的功能,让我们可以像这样做同样的事情……
from ..common.game_enums import GameMode
的意思..
是“这个包的直接父包”,在本例中是omission
。因此,导入操作后退一级,向下进入common
,然后找到game_enums.py
。
关于使用绝对导入还是相对导入,存在很多争议。我个人倾向于尽可能使用绝对导入,因为它能显著提升代码的可读性。不过,您可以自行决定。唯一重要的是,结果必须显而易见——任何内容的来源都不应令人感到神秘。
(继续阅读:真正的 Python - Python 中的绝对导入与相对导入
这里还有一个隐藏的陷阱!在 中omission/data/settings.py
,我有这样一行:
from omission.data.game_round_settings import GameRoundSettings
当然,由于这两个模块都在同一个包中,我们应该能够说from game_round_settings import GameRoundSettings
,对吗?
错了!它实际上会找不到game_round_settings.py
。这是因为我们正在运行顶级包omission
,这意味着搜索路径(Python 查找模块的位置和顺序)的工作方式有所不同。
但是,我们可以使用相对导入来代替:
from .game_round_settings import GameRoundSettings
在这种情况下,单曲的.
意思是“这个包裹”。
如果你熟悉典型的 UNIX 文件系统,那么你应该能理解这一点。..
表示“后退一级”,.
表示“当前位置”。当然,Python 更进一步:...
表示“后退两级”,....
表示“后退三级”,等等。
但是,请记住,这些“层级”并非仅仅是普通的目录,而是包。如果在一个并非包的普通目录中有两个不同的包,则无法使用相对导入从一个包跳转到另一个包。您必须使用 Python 搜索路径来实现这一点,但这超出了本指南的讨论范围。(请参阅本文末尾的文档。)
__main__.py
还记得我提到过__main__.py
在顶级包中创建一个 吗?这是一个特殊的文件,当我们直接用 Python 运行包时会执行它。我的omission
包可以从我的仓库根目录运行python -m omission
。
以下是该文件的内容:
from omission import app
if __name__ == '__main__':
app.run()
是的,就是这样!我正在app
从顶级包中导入我的模块omission
。
记住,我也可以说成from . import app
instead。或者,如果我只想说run()
instead of app.run()
,我也可以写成from omission.app import run
or from .app import run
。最终,只要代码可读性好,我如何导入并没有太大的技术区别。
(旁注:我们可以争论一下将app.py
我的主run()
功能分开是否合乎逻辑,但我有我的理由……而且它们超出了本指南的范围。)
一开始让大多数人感到困惑的是整个语句if __name__ == '__main__'
。Python 没有太多样板代码——这些代码几乎不需要修改就能通用——但这是其中罕见的部分之一。
__name__
是每个 Python 模块的一个特殊字符串属性。如果我把这行代码print(__name__)
放在 的顶部omission/data/settings.py
,当该模块被导入(并运行)时,我们就会看到打印出“omission.data.settings”。
当通过直接运行模块时python -m some_module
,该模块会被分配一个特殊值__name__
:“ main ”。
因此,实际上是在检查模块是否作为主if __name__ == '__main__':
模块执行。如果是,则根据条件运行代码。
app.py
你可以通过另一种方式来查看它的实际效果。如果我将以下内容添加到...的底部
if __name__ == '__main__':
run()
...然后我可以直接通过 执行该模块python -m omission.app
,结果与 相同python -m omission
。现在__main__.py
被完全忽略,并且__name__
的omission/app.py
是"__main__.py"
。
同时,如果我只是运行python -m omission
,那么中的特殊代码app.py
将被忽略,因为它__name__
现在omission.app
又是。
看看它是如何工作的?
总结
我们来回顾一下。
-
每个项目都应该使用一个版本控制系统 (VCS),例如 Git。有很多选择。
-
每个 Python 代码文件(
.py
)都是一个模块。 -
将模块组织到包中。每个包必须包含一个特殊
__init__.py
文件。 -
您的项目通常应包含一个顶级包,该包通常包含子包。该顶级包通常与您的项目同名,并以目录的形式存在于项目仓库的根目录中。
-
永远不要在 import 语句中使用
*
。在考虑可能的异常之前,Python 之禅指出“特殊情况不足以打破规则”。 -
使用绝对或相对导入来引用项目中的其他模块。
-
可执行项目应该
__main__.py
在顶级包中有一个 。然后,你可以直接使用 执行该包python -m myproject
。
当然,在构建 Python 项目时,我们还可以运用很多更高级的概念和技巧,但我们不会在这里讨论。我强烈建议您阅读以下文档:
感谢(Freenode IRC )、grym
@ cbrintnall和@rhymes (Dev) 提出的修改建议。deniska
#python