如何从Python包内部读取(静态)文件?
你能告诉我怎么读取我Python包里面的文件吗?
我的情况
我加载的一个包里面有一些模板(就是用作字符串的文本文件),我想在程序中加载这些模板。但是我该怎么指定这些文件的路径呢?
假设我想从以下路径读取一个文件:
package\templates\temp_file
是不是需要进行某种路径处理?或者跟踪包的基本路径呢?
6 个回答
在《Python Cookbook》第三版中,David Beazley和Brian K. Jones的“10.8. 在包内读取数据文件”部分提供了答案。
我就直接说到这里:
假设你有一个包,里面的文件组织结构如下:
mypackage/
__init__.py
somedata.dat
spam.py
现在假设文件spam.py想要读取somedata.dat文件的内容。要做到这一点,可以使用以下代码:
import pkgutil
data = pkgutil.get_data(__package__, 'somedata.dat')
这样得到的变量data会是一个字节字符串,里面包含了文件的原始内容。
get_data()的第一个参数是一个字符串,表示包的名称。你可以直接提供这个名称,或者使用一个特殊的变量,比如__package__
。第二个参数是文件在包内的相对名称。如果需要,你可以使用标准的Unix文件名规则进入不同的目录,只要最终的目录仍然在这个包内。
通过这种方式,包可以作为目录、.zip文件或.egg文件进行安装。
打包前言:
在你开始担心如何读取资源文件之前,第一步是确保数据文件能被打包到你的发布版本中。直接从源代码树读取文件很简单,但重要的是要确保这些资源文件在安装后的包中可以被代码访问。
你应该这样组织你的项目,把数据文件放在包的一个子目录中:
.
├── package
│ ├── __init__.py
│ ├── templates
│ │ └── temp_file
│ ├── mymodule1.py
│ └── mymodule2.py
├── README.rst
├── MANIFEST.in
└── setup.py
在调用 setup()
时,你需要传入 include_package_data=True
。如果你想使用 setuptools/distutils 来构建源代码分发,那么就需要一个清单文件。为了确保 templates/temp_file
能被打包到这个示例项目结构中,你需要在清单文件中添加一行:
recursive-include package *
历史遗留说明: 现代构建工具如 flit 和 poetry 不需要使用清单文件,它们默认会包含包的数据文件。所以,如果你使用 pyproject.toml
而没有 setup.py
文件,那么你可以忽略关于 MANIFEST.in
的所有内容。
现在,打包的事情解决了,接下来是读取部分……
推荐:
使用标准库中的 pkgutil
API。它在库代码中看起来是这样的:
# within package/mymodule1.py, for example
import pkgutil
data = pkgutil.get_data(__name__, "templates/temp_file")
它可以在 zip 文件中工作,支持 Python 2 和 Python 3,并且不需要第三方依赖。我不太清楚有什么缺点(如果你知道,请在回答中评论)。
避免的坏方法:
坏方法 #1:使用相对路径从源文件中读取
这个方法在之前的回答中提到过。最好的情况下,它看起来像这样:
from pathlib import Path
resource_path = Path(__file__).parent / "templates"
data = resource_path.joinpath("temp_file").read_bytes()
这有什么问题呢?假设你有文件和子目录的前提是不正确的。如果你的代码被打包成 zip 或 wheel,这种方法就不管用了,而且用户是否能将你的包解压到文件系统上也完全不在他们的控制之内。
坏方法 #2:使用 pkg_resources API
这个方法在投票最高的回答中提到。它看起来像这样:
from pkg_resources import resource_string
data = resource_string(__name__, "templates/temp_file")
这有什么问题呢?它增加了对 setuptools 的运行时依赖,而这应该仅仅是安装时的依赖。导入和使用 pkg_resources
可能会变得非常慢,因为代码会构建一个所有已安装包的工作集,尽管你只对你自己的包资源感兴趣。这在安装时不是大问题(因为安装是一次性的),但在运行时就显得很麻烦了。
坏方法 #3:使用过时的 importlib.resources API
这个方法目前曾经是投票最高的回答的推荐。自 Python 3.7 起,它就在 标准库中。它看起来是这样的:
from importlib.resources import read_binary
data = read_binary("package.templates", "temp_file")
这有什么问题呢?不幸的是,这个实现还有很多不足之处,并且可能会在 Python 3.11 中被弃用。使用 importlib.resources.read_binary
、importlib.resources.read_text
等方法需要你添加一个空文件 templates/__init__.py
,这样数据文件才能放在子包中,而不是子目录中。这也会将 package/templates
子目录暴露为一个可导入的 package.templates
子包。这对许多已经使用资源子目录而不是资源子包发布的现有包来说是行不通的,而且到处添加 __init__.py
文件会让数据和代码的边界变得模糊。
这个方法在 2021 年被 上游的 importlib_resources
弃用,并在 Python 3.11 版本的标准库中被弃用。bpo-45514 跟踪了这个弃用,而 从遗留版本迁移 提供了 _legacy.py
的包装器来帮助过渡。
值得一提:使用可遍历的 importlib.resources API
在我发布这个内容时(2020 年),投票最高的回答中没有提到这个,但作者后来在他们的回答中添加了它(2023 年)。importlib_resources
不仅仅是 Python 3.7+ importlib.resources
代码的简单移植。它有可遍历的 API,可以像 pathlib
一样访问资源:
import importlib_resources
my_resources = importlib_resources.files("package")
data = my_resources.joinpath("templates", "temp_file").read_bytes()
这个方法可以在 Python 2 和 3 中工作,支持 zip 文件,并且不需要在资源子目录中添加多余的 __init__.py
文件。我能想到的唯一缺点是,这些可遍历的 API 仅在 Python 3.9+ 的标准库 importlib.resources
中可用,因此仍然需要第三方依赖来支持旧版本的 Python。如果你只需要在 Python 3.9+ 上运行,那么就使用这个方法,或者你可以添加一个兼容层和一个 条件依赖 来支持旧版本的 Python:
# in your library code:
try:
from importlib.resources import files
except ImportError:
from importlib_resources import files
# in your setup.py or similar:
from setuptools import setup
setup(
...
install_requires=[
'importlib_resources; python_version < "3.9"',
]
)
在 Python 3.8 结束生命周期之前,我的推荐仍然是使用标准库中的 pkgutil
,以避免额外的条件依赖复杂性。
示例项目:
我在 GitHub 上创建了一个示例项目,并在 PyPI 上上传,展示了上述五种方法。你可以尝试一下:
$ pip install resources-example
$ resources-example
简而言之: 使用标准库的 importlib.resources
模块
如果你不需要兼容 Python 3.9 之前的版本(下面的第二种方法会详细解释),可以使用这个:
from importlib import resources as impresources
from . import templates
inp_file = impresources.files(templates) / 'temp_file'
with inp_file.open("rt") as f:
template = f.read()
详细信息
传统的 pkg_resources
来自 setuptools
不再推荐使用,因为新的方法:
- 性能上 显著更好;
- 更安全,因为使用包(而不是路径字符串)会在编译时产生错误;
- 更直观,因为你不需要“连接”路径;
- 仅依赖于 Python 的标准库(不需要额外的第三方依赖
setuptools
)。
我把传统方法放在前面,是为了在迁移现有代码时解释新旧方法的区别(迁移的详细信息 在这里解释)。
假设你的模板文件位于模块包中的一个文件夹里:
<your-package>
+--<module-asking-the-file>
+--templates/
+--temp_file <-- We want this file.
注意 1: 我们绝对不应该去修改
__file__
属性(例如,当从 zip 文件中提供时,代码会出错)。注意 2: 如果你正在构建这个包,记得在你的
setup.py
中声明你的数据文件为package_data
或data_files
。
1) 使用 pkg_resources
来自 setuptools
(慢)
你可以使用 pkg_resources
包来自 setuptools,但 这会有代价,在性能上:
import pkg_resources
# Could be any dot-separated package/module name or a "Requirement"
resource_package = __name__
resource_path = '/'.join(('templates', 'temp_file')) # Do not use os.path.join()
template = pkg_resources.resource_string(resource_package, resource_path)
# or for a file-like stream:
template = pkg_resources.resource_stream(resource_package, resource_path)
小贴士:
即使你的分发包是压缩的,这也会读取数据,所以你可以在你的
setup.py
中设置zip_safe=True
,或者使用期待已久的zipapp
打包工具来创建自包含的分发包。记得把
setuptools
加入你的运行时需求中(例如,在install_requires
中)。
... 并且请注意,根据 Setuptools/pkg_resources
的文档,你不应该使用 os.path.join
:
基本资源访问
注意,资源名称必须是
/
分隔的路径,不能是绝对路径(即不能以/
开头)或包含像 "..
" 这样的相对名称。不要使用os.path
的方法来处理资源路径,因为它们 不是 文件系统路径。
2) Python >= 3.7,或使用回移植的 importlib_resources
库
使用标准库的 importlib.resources
模块,它比上面的 setuptools
更高效:
try:
from importlib import resources as impresources
except ImportError:
# Try backported to PY<37 `importlib_resources`.
import importlib_resources as impresources
from . import templates # relative-import the *package* containing the templates
try:
inp_file = (impresources.files(templates) / 'temp_file')
with inp_file.open("rb") as f: # or "rt" as text file with universal newlines
template = f.read()
except AttributeError:
# Python < PY3.9, fall back to method deprecated in PY3.11.
template = impresources.read_text(templates, 'temp_file')
# or for a file-like stream:
template = impresources.open_text(templates, 'temp_file')
注意:
关于函数
read_text(package, resource)
:
package
可以是字符串或模块。resource
不再是路径,而只是要打开的资源的文件名,必须在现有包内;它不能包含路径分隔符,也不能有子资源(即不能是目录)。
对于问题中提到的例子,我们现在必须:
- 将
<your_package>/templates/
变成一个合适的包,通过在其中创建一个空的__init__.py
文件, - 这样我们就可以使用简单的(可能是相对的)
import
语句(不再需要解析包/模块名称), - 并且只需请求
resource_name = "temp_file"
(没有路径)。
小贴士:
- 要访问 当前模块 内的文件,将包参数设置为
__package__
,例如impresources.read_text(__package__, 'temp_file')
(感谢 @ben-mares)。- 当请求一个 实际文件名 时,使用
path()
会变得有趣,因为现在使用了上下文管理器来处理临时创建的文件(阅读 这个)。- 对于旧版本的 Python,条件性地添加回移植的库,使用
install_requires=[" importlib_resources ; python_version<'3.7'"]
(如果你用setuptools<36.2.1
打包项目,请查看 这个)。- 如果你从传统方法迁移过来,记得从你的 运行时需求 中移除
setuptools
库。- 记得自定义
setup.py
或MANIFEST
以 包含任何静态文件。- 你也可以在你的
setup.py
中设置zip_safe=True
。