栏目:02 · 后端开发(2 月 · 后端语言入门系列) 环境:Python 3.13.2 / pip 25.0 / setuptools 75.8.0 / pytest 8.3.4 / ruff 0.9.4
上个月接手组里一个内部工具,干的是每晚会从 ERP 拉库存数据再同步到分析库的活。前任写得不算差,逻辑清楚,注释也有,就是结构比较奔放:根目录一个 main.py,旁边散着 utils.py、db.py、config.py、sync_erp.py、sync_ods.py,外加一个 scripts/check_data.py。在我本机上跑得好好的,丢到服务器 crontab 里第一晚就炸了。
报错长这样:
text
Traceback (most recent call last):
File "scripts/check_data.py", line 8, in <module>
from ..db import get_conn
ImportError: attempted relative import with no known parent package
scripts/check_data.py 里用了一行相对导入 from ..db import get_conn。在我本机我用 python -m scripts.check_data 跑,没问题;crontab 里前任写的是 python scripts/check_data.py,Python 直接把它当成顶层脚本执行,__package__ 是空的,相对导入当场去世。同一份代码,换个运行方式就是两种命运,这事本身就说明结构有病。
我先用了最蠢的办法修
当时赶上线,我的第一反应是老三样:在报错文件开头糊一段------
python
import sys
from pathlib import Path
sys.path.append(str(Path(__file__).resolve().parent.parent))
能跑。然后第二晚 sync_erp.py 也炸了,我再糊一段。第三晚发现 crontab 的工作目录不对,config.py 里用相对路径读 config.yaml 读不到,我又糊了一段。一周后项目里散落着五处 sys.path 操作,每一处 subtle 地不一样(有的是 parent.parent,有的是 parent.parent.parent),我看着都觉得晦气。
这种补丁真正的问题是:它把"模块怎么被找到"这个本该由项目结构保证的事情,变成了运行时每个脚本各自为政的玄学。谁新写一个入口文件,谁就要再抄一遍咒语,抄错一级目录就是下一个深夜告警。
后来我才知道,问题的根子不在 crontab,也不在我抄错目录,而在于这个项目从头到尾就不是一个"包"------它是一堆脚本,只是恰好放在同一个文件夹里。
认知反转:工程化的关键不是分文件夹,是让项目可安装
我一开始以为"规范化项目结构"就是按类型把文件分好:models/、services/、utils/,看着整齐就行。做完发现没用,sys.path 的咒语一行都省不掉。
真正改变局面的是一个很简单的动作:给项目加一个 pyproject.toml,然后 pip install -e .。把项目当成一个可安装的包 ,而不是一个装脚本的文件夹。装完之后,任何目录下、任何入口方式,import inventory_sync 都能找到它,因为解释器去 site-packages 找的是包本身,跟你从哪启动、工作目录是什么再无关系。
先看坏味道版本,就是我接手时的样子:
text
inventory-tool/
├── main.py
├── db.py
├── config.py
├── sync_erp.py
├── scripts/
│ └── check_data.py
└── requirements.txt
改成可安装结构(src 布局):
text
inventory-tool/
├── pyproject.toml
├── README.md
├── src/
│ └── inventory_sync/
│ ├── __init__.py
│ ├── db.py
│ ├── config.py
│ └── sync_erp.py
├── scripts/
│ └── check_data.py
└── tests/
└── test_db.py
pyproject.toml 是这次的灵魂,完整贴出来(这是我实际在用的,setuptools 后端):
toml
[build-system]
requires = ["setuptools>=75.8.0"]
build-backend = "setuptools.build_meta"
[project]
name = "inventory-sync"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"requests>=2.32.3",
"pymysql>=1.1.1",
"pyyaml>=6.0.2",
]
[project.scripts]
inventory-sync = "inventory_sync.main:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"]
注意 [project.scripts] 这一段:装完之后系统里会多一个 inventory-sync 命令,crontab 里直接写这个命令,连 python xxx.py 都不用了。原来 main.py 里那堆脚本逻辑收进 inventory_sync/main.py,暴露一个 main() 函数:
python
# src/inventory_sync/main.py(Python 3.13.2 验证可运行)
import argparse
import logging
from inventory_sync.sync_erp import pull_and_sync
def main() -> None:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
parser = argparse.ArgumentParser(description="ERP -> ODS 库存同步")
parser.add_argument("--dry-run", action="store_true", help="只拉取不写库")
args = parser.parse_args()
pull_and_sync(dry_run=args.dry_run)
if __name__ == "__main__":
main()
安装就两步:
bash
pip install -e . # 可编辑安装,改代码立即生效
pip install -e ".[dev]" # 如果定义了 dev 依赖组,带上测试和 lint 工具
装完验证一下,在任何目录执行都行:
bash
$ python -c "import inventory_sync; print(inventory_sync.__file__)"
/home/deploy/inventory-tool/src/inventory_sync/__init__.py
-e 可编辑安装指向的是 src 里的源码,所以边改边跑没有重装的问题。之前那五处 sys.path.append 全删了,一行没留,crontab 从 python scripts/check_data.py 改成 inventory-sync --dry-run 之后到现在没再炸过。
src 布局的那个坑:它替我抓出过一个隐藏 bug
说实话,我最初觉得 src/ 这一层是脱裤子放屁------包目录外面再套一层目录,敲路径都多敲四个字符。我一度用的是"flat 布局"(包目录直接放根下),也挺好使。直到有一次我给包加了个新子模块 inventory_sync/exporters/,本地测试全绿,打了个 wheel 发给同事,人家一跑:
text
ModuleNotFoundError: No module named 'inventory_sync.exporters'
查下来原因是 flat 布局下 setuptools 的包发现机制有时会漏掉子包(尤其是没有 __init__.py 的隐式命名空间包,虽然我的问题出在打包配置写窄了),而本地测试一直全绿的原因更阴险:pytest 会把项目根目录插到 sys.path 最前面,测试导入的其实是仓库里的源码目录,根本不是安装的包。也就是说打包少没少东西,测试根本测不出来------它一直在测一个"不存在打包问题"的平行世界。
换成 src 布局后这个遮蔽就自动消失了:仓库里多了一层 src/,包不再和根目录同名,pytest 想导入只能老老实实走 site-packages 里的那个可编辑安装。打包缺了子模块,测试当场红。这是我把 src 布局留下来的真正原因------不是为了好看,是它把"测试环境"和"安装环境"强制对齐了。这一条我愿称之为本次重构里最值钱的一课。
对应的测试也顺了,tests/test_db.py 直接导入包,没有任何路径操作:
python
from inventory_sync.config import load_config
def test_config_has_db_section():
cfg = load_config("config.yaml")
assert "database" in cfg
配套的 lint 和 CI
结构定下来之后我顺手把 ruff 挂上了,上面 pyproject 里的 [tool.ruff] 部分就是全部配置。select 里我特意放了 I(isort 规则),因为它会强制检查 import 排序------在一个"谁依赖谁"曾经靠 sys.path 玄学维持的项目里,import 区干净至少说明没人再偷偷加路径。跑一遍:
bash
$ ruff check src tests
All checks passed!
$ ruff format --check src tests
4 files already formatted
CI 里就三行核心:装包、lint、测试。因为测试走的是安装后的包,CI 里"忘记 pip install"这种事会直接报 ModuleNotFoundError 而不是静默通过------这其实也是个 feature。
现在还剩什么没解决
包管理器我还在 pip + requirements 之间纠结要不要切 uv,组里另两个 Python 项目一个用 poetry 一个用 pip,三个项目三种工具,lock 文件互不相认,这件事比项目结构更值得统一,但动静也更大,打算下个迭代先拿这个库存工具当小白鼠。另外 scripts/check_data.py 这种运维脚本要不要也收进包里做成子命令,我还没想好------收进去安装就能用,但改一行要重新 pip install -e . 才在某些环境下生效,有点烦。如果你有更好的方案欢迎评论区打我。
这篇就到这。src 布局 + 可安装包这两件事,单看每件都不大,加起来大概花了我一个下午,换来的是删光 sys.path 咒语、crontab 零告警三周。对一直写脚本没建过包的同学,我只有一句实在话:别等 import 炸了才做这件事。