Python 工程化入门:从脚本到规范的 Python 项目结构

栏目: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 炸了才做这件事。

相关推荐
Yang96111 小时前
从相位噪声到实时带宽:HD-063B支撑的频谱实验项目
网络
happyprince1 小时前
01-整体观-DeepSeek-V4.1-Flash全貌解读
网络
MasonYyp1 小时前
asyncio并发协程
python
是真的小外套1 小时前
2023年软考中级信息安全工程师|选择题考点+5道案例分析真题解析(含答案)
java·服务器·网络
小小张说故事1 小时前
Python 的整数为什么永远不会溢出?拆开大整数对象,看 30 位一节的存储与运算代价
python
用户298698530142 小时前
Python 实现 Word 与 TIFF 双向转换的方案
后端·python·api
benchmark_cc2 小时前
实时行情和分钟 K 线应该如何配合使用?从信号计算到交易决策的完整分析
大数据·python·数据分析·pandas·量化交易·股票数据·quantdash
LMT流氓兔2 小时前
第四单元——第17课:让回答边生成、边显示
服务器·数据库·人工智能·python·oracle·langchain
MetWeave2 小时前
从零跑一个气象数据管道:抓取、清洗、对账、节奏
后端·python