第10章:Python模块、包与项目结构——从脚本到可安装库 1. 项目背景业务场景食光集市的技术团队在创业初期只有一个信念快。第一版代码几乎全是单体脚本——订单处理、库存管理、运费计算、菜单导入、对账工具几十个.py文件平铺在桌面上靠文件名加日期来区分版本order_v2_final_真的最终版.py。随着业务从北京扩展到上海、深圳团队从 2 人涨到 15 人问题开始集中爆发事件一「环形导入」地狱——新同事在order.py顶部写了from shop import Shop而shop.py里又有from order import get_order_count。启动一个简单的命令行工具Python 报出一长串ImportError: cannot import name Shop from partially initialized module。新同事花了半天排查最后得到的答案是——“把 import 移到函数内部”但这又违背了 PEP 8。事件二「脚本式 logging」失控——三个开发各自在脚本里调用了logging.basicConfig()由于basicConfig只在第一次调用时生效后两次的配置被静默忽略。线上日志时而有时而无排查一个问题需要问遍全组人你的脚本用的是什么 logger 配置事件三「命令行入口」碎片化——运维需要跑 7 个不同的工具清理日志、同步菜单、生成报表每个都是一句python scripts/xxx.py --config...。新人入职时拿到一张 A4 纸上面手写了 12 个命令。没人记得每个参数的含义和默认值。这些问题的共同根源是项目从脚本集合长成了小型系统但没有生长出模块结构和构建规范。痛点放大缺乏项目结构规划会导致环形导入A 引用 BB 引用 A——Python 的模块初始化是单线程顺序执行的循环引用必然导致部分模块未完成初始化就被导入。sys.path混乱不知道 Python 从哪里找模块——你可能导入了site-packages里的旧版本包而非项目内的最新代码。运行方式不一致有人python -m foodmarket.cli有人python src/foodmarket/cli.py——同一个代码跑出了两个不同的sys.path导入行为完全不同。日志满天飞每个模块自己配置日志 handler同一个事件被打印三次或者写入了三个不同的日志文件。2. 项目设计场景周三代码评审会上小胖打开一个新增的utils.py文件——里面塞着一个 300 行的函数是杂货铺型模块的典型代表。大师决定借这个机会重新整理项目结构。小胖抢在批评之前开口“我知道utils.py很丑但我也是没办法——我不知道该把这些函数放哪。放order.py吧它跟运费有关放delivery.py吧它又用到了订单状态。所以只能放utils.py……”小白“这不是文件命名的问题是模块边界没画清楚。小胖你想想——如果你的代码里有一些函数同时跟订单和配送有关那它在业务上应该属于什么可能是’结算’pricing也可能是’履约’fulfillment。找到这个业务概念就是这个模块的名字。”大师“小白说得对——模块划分的第一原则是业务内聚不是技术分类。不好的结构是按技术层分models.py、views.py、utils.pyDjango 的默认结构在小项目还行大了就是灾难。好的结构是按业务域分”# 差的结构——按技术分类 foodmarket/ models.py # 所有模型1000 行 utils.py # 所有工具函数3000 行 views.py # 所有视图5000 行 # 好的结构——按业务域分类 foodmarket/ shop/ # 店铺域 models.py services.py order/ # 订单域 models.py services.py pricing.py delivery/ # 配送域 routes.py calculator.py“关键原则一个模块解决一个问题而不是一个模块收集所有杂项。如果有一天你要把’配送域’拆成独立微服务你只需要拷贝delivery/目录。”技术映射按技术分类的模块 把厨房工具按金属的、“塑料的”、“木头的分类而不是按切菜的”、“炒菜的”、蒸菜的分类——名字清楚但做一道菜要跑四个柜子。小胖翻看代码库“那导入问题怎么搞我老遇到ImportError尤其是从一个包导入另一个包的时候。from ..shop.models import Shop这种两个点的到底是什么还有from . import services和import services的区别”小白“这个我知道一点——.是相对导入只能在包内部用。但我想问什么时候用绝对导入from foodmarket.shop.models import Shop什么时候用相对导入from .models import Shop还有__init__.py现在是否还是必需的Python 3.3 引入了隐式命名空间包__init__.py是不是可以删了”大师“两个层面的好问题。”“绝对导入 vs 相对导入——我推荐包内部用相对导入跨包用绝对导入。”# 在 foodmarket/order/services.py 中# 同包内——相对导入from.modelsimportOrder# 同目录的 models.pyfrom.pricingimportcalc_total# 同目录的 pricing.py# 跨包——绝对导入fromfoodmarket.shop.modelsimportShop# 跨业务域fromfoodmarket.common.loggingimportget_logger# 公共模块“这样做的优势是当你移动order/整个包时内部相对导入不受影响跨包的绝对导入清晰标明依赖关系。”“__init__.py的作用——它标记一个目录是常规包regular package。Python 3.3 引入了命名空间包没有__init__.py的包允许同一个包名分散在多个目录中。但对绝大多数项目来说每个包目录都保留__init__.py——它可以用来做包级别的初始化、控制from package import *的行为通过__all__、或者在 import 时执行一些注册逻辑。”“现代做法__init__.py可以留空但保留它作为显式标记——告诉下一个读代码的人’这是一个包不是一个普通目录’。”技术映射绝对导入 快递地址写完整省市区街道——不管你在哪都能寄到。相对导入 说隔壁那栋楼——在同一小区内很方便出了小区别人听不懂。小胖“那__name__ __main__呢我看每个脚本最后都有这个到底什么用”小白“对还有python -m和直接python file.py的区别。另外pyproject.toml和setup.py的关系——现在是不是已经彻底不用setup.py了”大师“两个打包生态的变迁问题。”“__name__ __main__——当 Python 文件被直接运行时__name__的值是__main__当它被作为模块导入时__name__是模块名如foodmarket.cli。这个判断保证了一个文件既可以被导入不执行 main 逻辑也可以被运行执行 main 逻辑。”python -m foodmarket.clivspython foodmarket/cli.py的关键区别python -m foodmarket.cli将项目根目录加入sys.path[0]__package__正确设置为foodmarket相对导入正常工作。python foodmarket/cli.py将文件所在目录加入sys.path[0]__package__为None相对导入直接报错。结论永远用python -m运行包内模块。“pyproject.tomlvssetup.py——2025 年的标准答案pyproject.toml是唯一需要的配置文件。setup.py/setup.cfg是旧时代的遗留除非你需要 C 扩展的复杂编译逻辑。最小pyproject.toml如下”[project] name foodmarket version 0.1.0 requires-python 3.13 dependencies [] [project.scripts] fm-cli foodmarket.cli:main“[project.scripts]会在pip install -e .后自动生成命令行入口运维只需要敲fm-cli --help就能看到所有可用命令。”技术映射pyproject.toml 新护照——全球通用一个证件搞定。setup.py 旧户口本——多个本本互相引用改一个还得改另一个。3. 项目实战食光集市项目骨架重构环境准备依赖版本说明Python3.13.14基准版本pytest8.3测试框架mkdirfoodmarket-ch10cdfoodmarket-ch10 python-mvenv .venv .venv\Scripts\activate pipinstallpytest分步实现步骤1搭建项目骨架目标src 布局 按业务域分包 pyproject.toml创建目录结构foodmarket-ch10/ ├── pyproject.toml ├── requirements.txt ├── src/ │ └── foodmarket/ │ ├── __init__.py │ ├── common/ │ │ ├── __init__.py │ │ └── logging_config.py │ ├── shop/ │ │ ├── __init__.py │ │ └── models.py │ ├── order/ │ │ ├── __init__.py │ │ ├── models.py │ │ └── pricing.py │ └── cli.py └── tests/ ├── __init__.py ├── test_shop.py └── test_order.pypyproject.toml[project] name foodmarket version 0.1.0 description 食光集市 - 核心业务模块 requires-python 3.13 dependencies [] [project.scripts] fm-audit foodmarket.cli:main [build-system] requires [setuptools75] build-backend setuptools.build_meta [tool.setuptools.packages.find] where [src]requirements.txtpytest8.3.4步骤2实现按业务域拆分的模块目标绝对导入 相对导入混用src/foodmarket/__init__.py食光集市 - 核心业务包__version__0.1.0src/foodmarket/common/__init__.py公共基础设施src/foodmarket/common/logging_config.py统一日志配置——只初始化一次importloggingdefsetup_logging(levellogging.INFO):配置根 logger——全局调用一次即可loggerlogging.getLogger()logger.setLevel(level)ifnotlogger.handlers:handlerlogging.StreamHandler()handler.setFormatter(logging.Formatter(%(asctime)s [%(levelname)s] %(name)s: %(message)s,datefmt%H:%M:%S,))logger.addHandler(handler)# 设置第三方库的日志级别避免噪音logging.getLogger(urllib3).setLevel(logging.WARNING)src/foodmarket/shop/__init__.py店铺域src/foodmarket/shop/models.py店铺模型fromdataclassesimportdataclass,fieldfromdatetimeimportdatetimefromdecimalimportDecimaldataclassclassShop:shop_id:strname:strcity:stris_open:boolTruecreated_at:datetimefield(default_factorydatetime.now)dataclassclassDish:dish_id:strname:strprice:Decimal shop_id:strstock:int0tags:list[str]field(default_factorylist)src/foodmarket/order/__init__.py订单域src/foodmarket/order/models.py订单模型fromdataclassesimportdataclass,fieldfromdatetimeimportdatetimefromdecimalimportDecimal# 跨包——绝对导入fromfoodmarket.shop.modelsimportShopdataclassclassOrderItem:dish_id:strname:strunit_price:Decimal quantity:intdataclassclassOrder:order_id:strshop_id:struser_id:stritems:list[OrderItem]field(default_factorylist)status:strpendingcreated_at:datetimefield(default_factorydatetime.now)src/foodmarket/order/pricing.py订单计价——纯函数fromdecimalimportDecimal# 同包内——相对导入from.modelsimportOrderdefcalc_order_total(order:Order,tax_rate:Decimal|NoneNone)-Decimal:计算订单总金额subtotalsum(item.unit_price*item.quantityforiteminorder.items)totalDecimal(str(subtotal))iftax_rate:total(total*tax_rate).quantize(Decimal(0.01))returntotal步骤3实现 CLI 入口目标python -m argparse __main__src/foodmarket/cli.py食光集市命令行工具importargparseimportloggingfromfoodmarket.common.logging_configimportsetup_logging loggerlogging.getLogger(__name__)defcmd_audit(args):审计命令——演示检查所有门店状态logger.info(执行门店审计...)# 模拟门店数据shops[{id:S01,name:老王烧烤,status:open},{id:S02,name:小李拉面,status:rest},]forshopinshops:logger.info(f{shop[id]}{shop[name]}:{shop[status]})return0defcmd_report(args):报表命令——演示生成日报logger.info(f生成{args.date}日报...)# 模拟报表逻辑print(f报表日期:{args.date})print(总订单: 1234)print(总金额: ¥12,345.67)return0defmain():parserargparse.ArgumentParser(progfm-audit,description食光集市运维工具集,)subparsersparser.add_subparsers(destcommand,help可用命令)# audit 子命令parser_auditsubparsers.add_parser(audit,help门店审计)parser_audit.add_argument(--city,help按城市过滤)parser_audit.set_defaults(funccmd_audit)# report 子命令parser_reportsubparsers.add_parser(report,help生成日报)parser_report.add_argument(--date,defaulttoday,help报表日期)parser_report.set_defaults(funccmd_report)argsparser.parse_args()ifnotargs.command:parser.print_help()return1setup_logging()returnargs.func(args)if__name____main__:exit(main())src/foodmarket/__main__.py允许通过 python -m foodmarket 直接运行fromfoodmarket.cliimportmainif__name____main__:exit(main())步骤4安装并验证 CLI目标可编辑安装 entry point 验证# 在项目根目录foodmarket-ch10/执行pipinstall-e.# 验证 entry point 安装成功fm-audit--help# 输出:# usage: fm-audit [-h] {audit,report} ...# 食光集市运维工具集fm-audit audit--city北京# 输出:# 16:30:01 [INFO] foodmarket.cli: 执行门店审计...# 16:30:01 [INFO] foodmarket.cli: S01 老王烧烤: open# 16:30:01 [INFO] foodmarket.cli: S02 小李拉面: rest# 也可以通过 python -m 运行python-mfoodmarket report--date2025-03-15可能遇到的坑pip install -e .后fm-audit命令找不到检查pyproject.toml中[project.scripts]的格式以及[tool.setuptools.packages.find]中where [src]。ImportError绝对导入失败pip install -e .后包被安装到site-packages以.pth链接此时绝对导入from foodmarket.shop.models import Shop才生效。如果没安装只能通过python -m在项目根目录运行。PowerShell 脚本锁定fm-audit实际是一个.exewrapperWindows 上确保 Scripts 目录在 PATH 中。步骤5编写测试目标验证跨包导入正确性tests/test_shop.py测试店铺模块fromdecimalimportDecimalfromfoodmarket.shop.modelsimportShop,Dishdeftest_create_shop():shopShop(S01,老王烧烤,北京)assertshop.name老王烧烤assertshop.is_openisTruedeftest_create_dish():dishDish(D01,宫保鸡丁,Decimal(28.00),S01,stock50)assertdish.priceDecimal(28.00)assertdish.stock50tests/test_order.py测试订单模块fromdecimalimportDecimalfromfoodmarket.order.modelsimportOrder,OrderItemfromfoodmarket.order.pricingimportcalc_order_totaldeftest_create_order():orderOrder(SG-001,S01,U01)assertorder.statuspendingdeftest_calc_order_total():orderOrder(SG-001,S01,U01)order.items[OrderItem(D01,宫保鸡丁,Decimal(28.00),2),OrderItem(D02,米饭,Decimal(3.00),3),]totalcalc_order_total(order)asserttotalDecimal(65.00)deftest_calc_order_total_with_tax():orderOrder(SG-001,S01,U01)order.items[OrderItem(D01,宫保鸡丁,Decimal(28.00),1),]totalcalc_order_total(order,tax_rateDecimal(0.06))asserttotalDecimal(29.68)运行测试pipinstall-e.python-mpytest tests/-v输出tests/test_shop.py::test_create_shop PASSED tests/test_shop.py::test_create_dish PASSED tests/test_order.py::test_create_order PASSED tests/test_order.py::test_calc_order_total PASSED tests/test_order.py::test_calc_order_total_with_tax PASSED 5 passed in 0.05s 完整代码清单foodmarket-ch10/ ├── pyproject.toml # 唯一配置文件 ├── requirements.txt ├── src/ │ └── foodmarket/ │ ├── __init__.py # 包标记 版本号 │ ├── __main__.py # python -m 入口 │ ├── common/ │ │ ├── __init__.py │ │ └── logging_config.py # 统一日志 │ ├── shop/ │ │ ├── __init__.py │ │ └── models.py │ ├── order/ │ │ ├── __init__.py │ │ ├── models.py │ │ └── pricing.py │ └── cli.py # argparse CLI └── tests/ ├── __init__.py ├── test_shop.py └── test_order.py测试验证pipinstall-e.python-mpytest tests/-vfm-audit audit--city北京# CLI 功能验证python-mfoodmarket report# python -m 方式验证4. 项目总结优点 缺点维度src 布局 pyproject.toml平铺脚本Django 默认结构monorepo (pants/bazel)学习成本★★★★★★★★★★★★★import 隔离★★★★★★★★★★★★★可安装性★★★★★★★★★★★★★大型项目管理★★★★★★★★★★★★CI/CD 兼容★★★★★★★★★★★★★★★★适用场景中大型项目3 开发人员src 布局 按业务域分包是当前 Python 社区的主流实践。需要 CLI 工具的内部项目pyproject.toml的[project.scripts]让运维和开发用同一个入口。准备发布到 PyPI 的库src 布局避免本地开发时意外导入未构建的源码。微服务拆分的前置准备按业务域分包后拆分微服务只需提取对应目录。新人快速上手看目录名就知道代码在哪——要改订单逻辑就去order/要改菜单就去shop/。不适用场景一次性脚本/数据分析单文件.py直接跑不需要项目结构。Jupyter Notebook 为主的数据探索Notebook 的路径解析方式与 py 文件不同src 布局的 import 可能需要额外配置。注意事项sys.path[0]取决于运行方式python -m foodmarket.cli的sys.path[0]是当前目录fm-auditentry point的sys.path[0]是 Scripts 目录。永远不要依赖sys.path[0]的具体值。__init__.py里不要放耗时操作import 时会执行拖慢启动。只放常量和简单的注册逻辑。相对导入只在包内部有效from .models import Order在直接python order/models.py时会报错__package__为 None。.gitignore中必须排除*.egg-info/、__pycache__/、.venv/。常见踩坑经验故障案例1pip install -e .后 import 的是旧代码现象改了源码但fm-audit运行结果不变。根因pip install -e .生成.egg-link文件指向项目目录但 Python 缓存了旧的.pyc文件。修复find . -name __pycache__ -exec rm -rf {} 清除缓存或用python -Xfrozen_modulesoff禁用某些缓存。故障案例2src布局下 tests 无法 import 包现象pytest报ModuleNotFoundError: No module named foodmarket。根因tests 在src外部Python 默认找不到src/foodmarket。修复先pip install -e .推荐或在pyproject.toml中配置[tool.pytest.ini_options]添加pythonpath [src]。故障案例3多个logging.basicConfig导致日志丢失现象第一个脚本调用basicConfig配置了文件输出第二个脚本也调用了但被忽略结果第二个脚本的日志没有写入文件。根因basicConfig只在第一次调用时生效内部有_has_handlers标记。修复在项目公共模块中统一初始化一次 logging如common/logging_config.py所有模块用logging.getLogger(__name__)获取自己的 logger不再调用basicConfig。思考题当你在foodmarket/order/pricing.py中使用from .models import Order相对导入然后直接执行python foodmarket/order/pricing.py会发生什么为什么如何正确测试这个模块如果一个项目的结构是myproject/ __init__.py module_a.py subpkg/ __init__.py module_b.py在module_b.py中写from ..module_a import func是否合法在什么条件下这个导入能成功答案见基础篇综合实战章附录。延伸阅读与资源Python 3实战精进从脚本到高并发订单引擎MongoDB 实战进阶与内核修炼python入门Rquests从菜鸟脚本到企业级SDK的网络实战圣经Milvus向量数据库实战修炼从 0 到 1精通向量检索与生产落地后端工程师的 AI 转型第一课Ollama 与私有化大模型实战10倍开发者的 Dify 魔法书从零构建全栈 AI 应用后端工程师转型AI第一课-Ollama 与私有化大模型实战大型语言模型(LLM) vLLM 高性能推理落地实战Agent开发之LlamaIndex 实战修炼与源码进阶大语言模型Transformers 实战修炼与源码剖析