原创

Python 包资源不一定是普通文件:从 ZIP 导入验证 importlib.resources

源码目录里能读到defaults.json,打包后却报文件不存在。先不要给工作目录补一段相对路径:资源不一定还是普通文件,包也可能从ZIP中导入。

Path(__file__).parent / 'data/defaults.json'依赖模块在普通文件系统上的布局。importlib.resources则通过包的导入器访问资源,让代码关心“这个包提供了哪份数据”,而不是猜安装目录在哪里。

下面用Python 3.12制作一个真实可导入的ZIP包。实验同时验证三件事:脱离项目工作目录仍能读取,按资源接口读文本不需要真实路径,确实需要路径时只在as_file的上下文期间使用它。这里的ZIP用于清楚展示边界,不把手工压缩包称作wheel安装流程。

先把资源放进包,而不是散落在启动目录

新建一个空目录resources-demo,文件结构如下:

resources-demo/
  build_zip.py
  run_bundle.py
  bundlekit/
    __init__.py
    reader.py
    data/
      defaults.json

init.py留空。data/defaults.json写入:

{"theme":"night","pageSize":20,"banner":"资源跟着包走"}

reader.py只提供两个明确操作:读取JSON数据,或者在调用者提供的目标路径保存一份副本。它不修改包内资源,也不把临时路径藏到全局变量。

import json
import shutil
from pathlib import Path
from importlib.resources import files, as_file


def defaults_resource():
    return files('bundlekit').joinpath('data').joinpath('defaults.json')


def load_defaults() -> dict:
    value = json.loads(defaults_resource().read_text(encoding='utf-8'))
    if not isinstance(value, dict) or value.get('pageSize') != 20:
        raise ValueError('unexpected defaults format')
    return value


def copy_defaults(destination: Path) -> None:
    # The destination is caller-owned; this example refuses replacement.
    with defaults_resource().open('rb') as source:
        with destination.open('xb') as target:
            shutil.copyfileobj(source, target)


def use_with_path(callback):
    # The callback must complete its use before this context exits.
    with as_file(defaults_resource()) as path:
        return callback(path)

files返回可以遍历的资源对象,未必是pathlib.Path。读取文本直接指定UTF-8;二进制读取则使用open('rb')。这两种操作都不需要先把资源落到一个长期可见的文件路径。

load_defaults里的字段检查是演示版本合同,说明“文件能读”之后仍应验证内容。真实默认配置可以使用完整模型校验,不应因为数据随代码发布,就假定任何版本混用都能被接受。

制作包含数据的 ZIP

保存build_zip.py。它只收集列出的包文件,输出到本项目dist目录,避免把虚拟环境、缓存和运行产物一起打进包。

from pathlib import Path
from zipfile import ZipFile, ZIP_DEFLATED

root = Path(__file__).resolve().parent
members = [
    'bundlekit/__init__.py',
    'bundlekit/reader.py',
    'bundlekit/data/defaults.json',
]
out = root / 'dist' / 'bundlekit.zip'
out.parent.mkdir(exist_ok=True)
with ZipFile(out, 'w', compression=ZIP_DEFLATED) as archive:
    for member in members:
        archive.write(root / member, member)
print(out)

执行 python3 build_zip.py,检查ZIP内确实有defaults.json。资源API只负责访问已经交付的内容,不能把漏打包的数据凭空找回来。将来改成wheel时,也必须在构建配置中包含资源,并检查最终发布物,而不只检查源码树。

构建脚本会覆盖本项目的同名ZIP,是为了反复运行实验;它不会覆盖调用者配置。reader里的copy_defaults使用xb,恰好是另一种合同:目标属于使用者,已有文件必须拒绝替换。不能因为都在“写文件”,就让两者共享含糊的覆盖规则。

从 ZIP 导入,再主动换到陌生工作目录

run_bundle.py接受刚才生成的ZIP绝对路径。把ZIP放到搜索路径最前面,确保这次没有被旁边的源码包抢先满足导入。随后切到临时目录,排除对当前工作目录的暗中依赖。

import os
import sys
import tempfile
from pathlib import Path

if len(sys.argv) != 2:
    raise SystemExit('usage: python3 run_bundle.py /absolute/path/bundlekit.zip')
archive = Path(sys.argv[1]).resolve(strict=True)
sys.path.insert(0, str(archive))
import bundlekit
from bundlekit.reader import load_defaults, copy_defaults, use_with_path

print('imported:', bundlekit.__file__)
assert str(archive) in bundlekit.__file__
assert not Path(bundlekit.__file__).is_file()
old_cwd = Path.cwd()
with tempfile.TemporaryDirectory(prefix='resource-run-') as directory:
    try:
        os.chdir(directory)
        config = load_defaults()
        print('loaded:', config['theme'], config['pageSize'], config['banner'])
        copied = Path('copied-defaults.json')
        copy_defaults(copied)
        assert copied.read_text(encoding='utf-8').strip().startswith('{')
        try:
            copy_defaults(copied)
            raise AssertionError('existing destination should be rejected')
        except FileExistsError:
            print('existing target: rejected')
        captured = []
        def consume(path):
            captured.append(path)
            assert path.is_file()
            return path.read_text(encoding='utf-8')
        text = use_with_path(consume)
        assert 'pageSize' in text
        print('temporary path after context:', captured[0].exists())
        assert not captured[0].exists()
    finally:
        os.chdir(old_cwd)
print('finished')

先构建,再把输出的绝对ZIP路径传给运行器:

python3 build_zip.py
python3 run_bundle.py "$PWD/dist/bundlekit.zip"

打印的导入位置应包含bundlekit.zip;它看起来像路径,却不是可以直接is_file为真的普通文件。随后loaded显示night、20和中文横幅,已有目标被拒绝,as_file上下文结束后临时文件不再存在。

临时路径断言专门针对本例ZIP资源。若同一代码从普通目录包导入,as_file可能直接提供已有路径,上下文结束后文件仍然存在;接口保证使用期间可用,不保证每次都复制,也不保证所有路径退出后删除。

把资源缺失当成发布物失败,而不是工作目录问题

如果打包脚本漏了JSON,Python模块仍可能正常导入,直到第一次访问资源才出错。只运行import bundlekit的冒烟检查不够;需要核对交付物成员,并实际执行用到资源的入口。否则部署状态看似成功,第一位使用者才触发缺文件。

可以再保存check_delivery.py,明确列出本应用必须交付的文件。它只检查ZIP成员,不执行包内代码。

import sys
from zipfile import ZipFile

required = {
    'bundlekit/__init__.py',
    'bundlekit/reader.py',
    'bundlekit/data/defaults.json',
}
if len(sys.argv) != 2:
    raise SystemExit('usage: python3 check_delivery.py /path/bundlekit.zip')
with ZipFile(sys.argv[1]) as archive:
    missing = required - set(archive.namelist())
    corrupt = archive.testzip()
if missing:
    raise SystemExit('missing resources: ' + ', '.join(sorted(missing)))
if corrupt:
    raise SystemExit('corrupt member: ' + corrupt)
print('delivery members: complete')

先运行它检查正常ZIP。然后在build_zip.py的members里临时移除defaults.json,重新构建同名实验包,再检查,应得到missing resources并以非零退出。之后恢复成员列表,重新构建,最后再次运行run_bundle.py;不能让故意损坏的实验产物留给后续使用。

成员完整也不代表业务内容正确,所以check_delivery与load_defaults的校验都保留。前者发现漏交文件,后者发现格式或内容与当前代码不匹配。两项证据都比“源码树中看得见文件”更接近实际部署边界。

资源更新还应跟随包版本,不要在部署后直接改安装目录的JSON。那会让同一个代码版本对应多份不可追踪默认值,ZIP与普通目录部署也会出现不同修改方式。需要运行时可变的设置,应放进明确的外部配置,并规定它如何覆盖包内默认值。

测试也应清楚自己导入了哪个版本。Python进程会缓存已经导入的模块,构建完新ZIP却在同一解释器里重复import,不一定重新加载资源模块。本文每次用新的进程启动运行器,且打印导入位置,减少开发会话残留掩盖交付问题的机会。

需要真实路径的库,必须在作用域内完成工作

有些第三方接口只接受文件名,这时as_file能弥合资源与普通路径的差异。但应在with内部调用并完成读取,不能把path返回给以后才执行的任务。

示例把消费者放到callback里,是为了让路径使用的有效期清楚。callback若只启动后台线程就返回,仍然违反这项约定;后台真正打开文件时,临时内容可能已经删除。应等待消费者完成,或者明确复制到调用方拥有、能管理生命周期的稳定目录。

消费者如果会修改文件,也不能直接拿包资源当工作文件。在普通目录部署时可能损坏安装内容,在ZIP部署时又只是改临时副本,两种环境产生不同副作用。需要可写配置时,先复制默认值到应用数据目录,再把那份使用者配置交给后续流程。

这里的copy_defaults在复制途中失败,可能留下一份不完整的目标文件;它的作用只是演示拒绝覆盖,未宣称原子发布。真实配置初始化如果需要失败时完全不出现目标,应采用同目录临时文件和适合目标文件系统的发布协议,并处理并发创建,而不是把xb理解成完整事务。

版本边界留在入口,不留在碰巧可用的目录结构里

Python 3.12的files参数采用anchor概念,并可由调用环境推导;本文始终显式传包名,避免复制到其他模块以后改变资源定位。支持更老Python时,需要按目标版本核对API,不能只在开发机验证一次。

路径片段也由程序固定。若将来让用户选择资源名,应限制到允许集合,并检查遍历边界,不要把任意输入拼成包内外路径。包资源是应用交付内容,不应变成通用文件读取接口。

排错时先确认包从哪里导入,再列出实际发布物成员,最后单独测试read_text。这样能区分导错版本、资源漏打包与编码错误。靠改变工作目录让一台机器恢复,只能掩盖问题,无法证明下一份部署也能找到同一资源。

完成这组实验后,可以暂时把源码目录改名,再重复运行ZIP路径入口。只要运行器与压缩包还在,资源读取仍应成立;这才说明程序依赖的是交付包,而不是开发目录里恰好存在的一份文件。

正文到此结束
评论插件初始化中...
Loading...