Python 相对导入报错:用 -m、包入口和 Linux 启动脚本修复
把一个 Python 脚本拆成包后,运行 python3 triage_demo/cli.py,却报 ImportError: attempted relative import with no known parent package。文件明明都在,问题往往不是少装了依赖,而是启动方式没有建立包上下文。
本文面向初级到中级读者,用一个文本行数工具复现错误,再改成 python3 -m 入口。最后加一个固定工作目录的 Linux 启动脚本,并验证换目录、空格文件名和失败退出;不安装第三方包,不修改系统配置。
1. 方法:区分文件入口与模块入口
Python 包内相对导入说明解释了点号导入与当前包的关系。这里的 from .helpers import count_lines 指的是同一个包中的 helpers,不是“当前工作目录下的文件”。
python3 triage_demo/cli.py把文件作为入口,本例没有可供相对导入使用的父包。python3 -m triage_demo.cli通过模块名启动,保留triage_demo包上下文。python3 -m triage_demo则运行包里的__main__.py,可以作为统一命令入口。
-m 官方文档要求使用模块名,不加 .py 或路径分隔符。本文在未安装包的源码目录中运行,不依赖全局 PYTHONPATH,也不在业务代码里插入 sys.path。
2. 准备环境与最小包
需要已有的 Python 3.9 或更新版本,以及 Linux 的 sh、bash 或 zsh。以下示例在 Python 3.11.4 上验证;所有文件都放进新建的演示目录。重复实验时换一个目录名。
- 在同一个终端中运行:
python3 --version
python3 --help
mkdir relative-import-demo
cd relative-import-demo
demo_root="$PWD"
mkdir triage_demo data
: > triage_demo/__init__.py
printf 'alpha\nbeta\ngamma\n' > data/items.txt
- 把下面保存为
triage_demo/helpers.py。按文本行计数,空行也算一行,不是 CSV 记录统计器:
from pathlib import Path
def count_lines(filename):
with Path(filename).open(encoding="utf-8") as source:
return sum(1 for _ in source)
- 保存为
triage_demo/cli.py:
import argparse
import sys
from .helpers import count_lines
def main():
parser = argparse.ArgumentParser(description="Count UTF-8 text lines.")
parser.add_argument("input", help="text file to read")
args = parser.parse_args()
try:
lines = count_lines(args.input)
except (OSError, UnicodeError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
print(f"lines={lines}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
__init__.py 在这个例子中是空文件,用来构成普通包。main() 处理命令行,读取失败向 stderr 写入错误并返回 1;缺少参数由 argparse 返回 2。若要扩展多个操作,可参考 argparse 子命令与参数校验。
3. 复现错误,再用 -m 修复
- 保持在
relative-import-demo,故意直接运行文件:
if python3 triage_demo/cli.py data/items.txt; then
printf 'Unexpected success: inspect the package and environment\n'
else
printf 'Expected failure: direct file execution lost package context\n'
fi
traceback 应包含:
ImportError: attempted relative import with no known parent package
if 包住了这次预期失败,避免开启 set -e 的 shell 提前退出。仅仅添加 if __name__ == "__main__" 不会修复包上下文;本例已经有入口保护,仍在导入处失败。
- 不改导入代码,改用模块名启动:
python3 -m triage_demo.cli --help
python3 -m triage_demo.cli data/items.txt
第一条显示帮助,第二条输出 lines=3,退出码为 0。想确认正常导入时的模块名和父包,可运行:
python3 -c 'import triage_demo.cli as m; print(m.__name__); print(m.__package__)'
预期分别是 triage_demo.cli 和 triage_demo。不要把相对导入改成裸 from helpers import ... 来勉强适配一种启动方式;这会改变模块身份和查找位置。
4. 增加包入口与稳定的 Linux 启动脚本
- 把下面保存为
triage_demo/__main__.py。它只转交给已有的main(),不复制业务逻辑:
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())
Python 的包入口说明介绍了 __main__.py。本例加上入口保护,使导入这个文件时也不会解析命令行或启动任务。
python3 -m triage_demo data/items.txt
仍应输出 lines=3。-m 不会自动寻找任意磁盘目录中的项目。 本文的包没有安装,必须从包目录的父目录启动;在其他目录运行同样的命令,可能报找不到 triage_demo。
- 在演示目录根部保存
launch.sh:
#!/bin/sh
set -eu
project_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
cd -- "$project_dir"
exec "${PYTHON:-python3}" -m triage_demo "$@"
脚本先切到自身所在目录,再以模块名执行;"$@" 保留参数边界,exec 让 Python 的退出码直接传给调用者。PYTHON 可指定一个已有解释器的绝对路径,未指定时使用 PATH 中的 python3。它是单个可执行文件路径,不是可以混入参数的命令字符串。
- 从另一个工作目录调用:
chmod u+x launch.sh
(
cd /
sh "$demo_root/launch.sh" data/items.txt
)
预期仍是 lines=3。括号让 cd / 只影响子 shell。这个启动脚本约定输入相对路径从项目根目录计算;若要读取调用者目录里的文件,请传绝对路径。只执行可信项目,不通过符号链接启动本例脚本,也不要把它当作适用于所有打包布局的安装器。
真实定时任务还要固定解释器并处理环境差异,见 Linux 定时任务路径与环境排查。本文只演示启动,不修改调度器。
5. 自动验证成功与失败分支
把下面保存为根目录的 verify_demo.py,与 launch.sh 同级。它读取前面创建的包,在自己的临时目录放测试输入,结束后自动清理;不会删除你的演示目录。
from pathlib import Path
import os
import subprocess
import sys
import tempfile
root = Path(__file__).resolve().parent
env = os.environ.copy()
for key in list(env):
if key.startswith("PYTHON"):
del env[key]
env["PYTHON"] = sys.executable
def run(args, cwd):
return subprocess.run(
args, cwd=cwd, env=env, capture_output=True,
text=True, timeout=10, check=False,
)
with tempfile.TemporaryDirectory(prefix="module-entry-test-") as tmp:
outside = Path(tmp)
source = outside / "input with spaces.txt"
source.write_text("alpha\n\ngamma\n", encoding="utf-8")
path = str(source)
direct = run([sys.executable, "triage_demo/cli.py", path], root)
assert direct.returncode == 1 and not direct.stdout, direct
assert "attempted relative import" in direct.stderr, direct
for module in ["triage_demo.cli", "triage_demo"]:
good = run([sys.executable, "-m", module, path], root)
assert (good.returncode, good.stdout, good.stderr) == (0, "lines=3\n", ""), good
foreign = run([sys.executable, "-m", "triage_demo", path], outside)
assert foreign.returncode != 0 and "No module named triage_demo" in foreign.stderr, foreign
wrapped = run(["sh", str(root / "launch.sh"), path], outside)
assert (wrapped.returncode, wrapped.stdout, wrapped.stderr) == (0, "lines=3\n", ""), wrapped
help_result = run([sys.executable, "-m", "triage_demo", "--help"], root)
assert help_result.returncode == 0 and "Count UTF-8 text lines." in help_result.stdout, help_result
no_arg = run([sys.executable, "-m", "triage_demo"], root)
assert no_arg.returncode == 2 and "required" in no_arg.stderr, no_arg
missing = run([sys.executable, "-m", "triage_demo", str(outside / "missing.txt")], root)
assert missing.returncode == 1 and missing.stderr.startswith("ERROR:"), missing
bad = outside / "bad.txt"
bad.write_bytes(b"\xff")
encoding = run([sys.executable, "-m", "triage_demo", str(bad)], root)
assert encoding.returncode == 1 and "ERROR:" in encoding.stderr, encoding
imported = run([sys.executable, "-c", "import triage_demo.cli; import triage_demo.__main__; print('import OK')"], root)
assert (imported.returncode, imported.stdout, imported.stderr) == (0, "import OK\n", ""), imported
print("OK: direct failure, module entry, package entry, foreign cwd, launcher, help, errors, safe import")
python3 verify_demo.py
预期输出:
OK: direct failure, module entry, package entry, foreign cwd, launcher, help, errors, safe import
测试检查了直接执行失败、两种模块入口成功、错误工作目录失败、启动脚本成功、空格路径、空行计数、帮助、缺少参数、文件不存在、坏 UTF-8 和安全导入。子进程有 10 秒等待上限,测试移除了继承的 PYTHON* 设置,再为启动脚本指定当前解释器,以隔离外部路径设置的干扰。
6. 常见错误与具体修复
| 现象 | 处理 |
|---|---|
attempted relative import with no known parent package |
从项目根目录用 python3 -m triage_demo.cli,不要直接执行包内文件。 |
No module named triage_demo |
确认当前目录包含 triage_demo/;改用绝对路径调用 launch.sh。已安装项目则检查对应解释器环境。 |
No module named triage_demo.__main__ |
创建第 4 节的 __main__.py,或直接运行 -m triage_demo.cli。 |
attempted relative import beyond top-level package |
检查点号层数和真实包层级;换启动方式不能修复写错的包关系。 |
| 包能加载,但读不到输入文件 | 本文启动脚本以项目根目录解释相对路径,改传输入的绝对路径。 |
| 模块名称正确,来源却不对 | 检查本地同名文件或包,按模块遮蔽与导入路径排查确认实际来源。 |
不要为了一个导入错误全局安装同名包,也不要批量删除 __pycache__。先核对命令、工作目录和完整 traceback,再判断是入口问题、模块遮蔽,还是依赖环境问题。
7. 小结
包内相对导入需要正确的包上下文,文件存在不代表直接执行包内文件就是正确入口。保留业务模块,用 -m 和一个薄 __main__.py 统一启动,再通过固定工作目录的脚本对接自动化。用失败复现和成功断言一起验证,能避免“终端能跑、定时任务找不到包”的反复排障。
- 原文作者:春江暮客
- 原文链接:https://www.bobobk.com/python-relative-import-module-entry.html
- 版权声明:本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。