前言讲模块和包的文章很多但大多停在「import可以导入东西」这一层。真到动手写项目时问题会变成日志工具放哪、配置怎么传、公共函数和被业务模块共享的数据怎么组织、脚本入口怎么留。这些都属于目录结构与导入路径的工程问题光知道语法不够。本文用一个可以照着跑的小项目把「模块、包、子包、__init__.py、__main__入口、sys.path」串起来。所有示例只依赖标准库复制到磁盘上就能执行。需要先纠一个说法题面里说的「包和模块实例」指的是包与模块的实例代码即一组用来演示包/模块用法的可运行示例它不是指「对包和模块做实例化」这种操作包和模块本身不是用来new的对象。本文按前者写。一、项目骨架假设我们要写一个小型的「库存记账」工具需求很朴素记录商品、算总价、按类别汇总、能导出文本报表。结构大致这样inventory/├── app.py└── inv/├── __init__.py├── models.py├── calc.py├── report.py└── formats/├── __init__.py├── plain.py└── table.py这里inv是包inv.formats是子包每个.py是模块。分层的理由models只放数据结构calc只放计算report负责编排formats承担输出格式将来加个 JSON 输出只需要新增一个模块。二、数据模型与计算模块数据模型模块# 适用于 Python 3.8# 文件名 inv/models.py库存的数据结构。from dataclasses import dataclass, fielddataclassclass Item:name: strcategory: strunit_price: floatquantity: int 1def total(self) - float:单价乘以数量。return self.unit_price * self.quantitydataclassclass Inventory:items: list field(default_factorylist)def add(self, item: Item) - None:加入一件商品名字重复时累加数量。for existing in self.items:if existing.name item.name and existing.category item.category:existing.quantity item.quantityreturnself.items.append(item)dataclasses从 Python 3.7 起提供3.8 及以上都能用。field(default_factorylist)是必须的写法如果直接写items: list []所有Inventory实例会共享同一个列表这是可变默认值的经典坑。计算模块与子包# 适用于 Python 3.8# 文件名 inv/calc.py基于 Inventory 的统计计算。from collections import defaultdictfrom .models import Inventorydef grand_total(inv: Inventory) - float:所有条目的总金额。return sum(item.total() for item in inv.items)def by_category(inv: Inventory) - dict:按类别汇总金额返回普通字典。buckets defaultdict(float)for item in inv.items:buckets[item.category] item.total()return dict(buckets)def top_items(inv: Inventory, limit: int 3) - list:按金额从高到低取前若干条limit 小于等于 0 时返回空列表。if limit 0:return []ordered sorted(inv.items, keylambda i: i.total(), reverseTrue)return ordered[:limit]注意from .models import Inventory这个相对导入它要求calc模块是以包内身份被加载的也就是从包外面用python -m或import进入而不能直接python inv/calc.py跑。defaultdict(float)的默认值是0.0所以第一次不会报KeyError。最后dict(buckets)把defaultdict转成普通字典避免把这个「会自动补键」的特性泄漏给调用者。子包里的输出格式模块# 适用于 Python 3.8# 文件名 inv/formats/plain.py纯文本输出。def render(rows):rows 是 (名称, 类别, 金额) 的序列返回多行文本。lines []for name, category, amount in rows:lines.append(f{name}\t{category}\t{amount:.2f})return \n.join(lines)# 适用于 Python 3.8# 文件名 inv/formats/table.py对齐的表格输出。def render(rows):rows 是 (名称, 类别, 金额) 的序列返回对齐后的多行文本。materialized list(rows)if not materialized:return (空)widths [max(len(str(row[i])) for row in materialized)for i in range(3)]sep .join(- * (w 2) for w in widths) lines [sep]for row in materialized:cells (str(row[0]).ljust(widths[0]),str(row[1]).ljust(widths[1]),f{row[2]:.2f}.rjust(widths[2]),)lines.append(| | .join(cells) |)lines.append(sep)return \n.join(lines)子包的__init__.py把两个渲染函数都收进来方便按名字取# 适用于 Python 3.8# 文件名 inv/formats/__init__.py输出格式集合。from .plain import render as render_plainfrom .table import render as render_tableRENDERERS {plain: render_plain,table: render_table,}__all__ [render_plain, render_table, RENDERERS]顶层包的__init__.py# 适用于 Python 3.8# 文件名 inv/__init__.py库存记账小工具。from .calc import grand_total, by_category, top_itemsfrom .models import Item, Inventory__all__ [Item,Inventory,grand_total,by_category,top_items,]__version__ 1.2.0report.py负责把上面几块缝起来# 适用于 Python 3.8# 文件名 inv/report.py报表编排。from .calc import by_category, grand_totalfrom .formats import RENDERERSdef category_rows(inv, fmttable):返回按类别汇总的渲染结果。if fmt not in RENDERERS:raise ValueError(f未知格式{fmt!r}可选 {sorted(RENDERERS)})rows [(name, 类别合计, amount) for name, amount in sorted(by_category(inv).items())]return RENDERERS[fmt](rows)def summary(inv):返回 (条目数, 总金额) 二元组。return len(inv.items), grand_total(inv)三、入口脚本与__main__# 适用于 Python 3.8# 文件名 inventory/app.py命令行入口。from inv import Inventory, Itemfrom inv.report import category_rows, summarydef build_demo():构造一份演示数据。inv Inventory()inv.add(Item(杯子, 厨房, 12.5, 4))inv.add(Item(盘子, 厨房, 8.0, 6))inv.add(Item(台灯, 照明, 45.0))inv.add(Item(灯泡, 照明, 6.5, 10))inv.add(Item(灯泡, 照明, 6.5, 2)) # 名字与类别都相同数量累加return invdef main():inv build_demo()print(category_rows(inv, fmttable))count, total summary(inv)print(f条目数{count}总金额{total:.2f})if __name__ __main__:main()推导一遍中间值杯子 12.5 × 4 50.0盘子 8.0 × 6 48.0台灯 45.0 × 1 45.0灯泡 6.5 × 10 65.0再加 6.5 × 2 13.0累加后数量为 12金额 78.0。厨房合计 98.0照明合计 123.0合计 221.0。条目数为 4灯泡两行被合并成一条。运行方式与对应结果运行命令是否成功原因在inventory目录下python app.py成功脚本目录进sys.pathinv可被搜到在inventory上一级python inventory/app.py成功同上加入的是inventory目录在inv目录下python calc.py失败相对导入from .models import ...找不到父包在inventory下python -m inv.calc可导入但无输出以包内身份加载相对导入成立四、再谈模块搜索开发期怎么让包能被找到如果入口脚本不在包的旁边最省事的两种办法是第一种设置环境变量PYTHONPATH把包的父目录加进去export PYTHONPATH/path/to/projectpython /path/to/project/tools/run.py第二种在代码里临时改sys.path仅限临时调试# 适用于 Python 3.8import sysfrom pathlib import Pathsys.path.insert(0, str(Path(__file__).resolve().parent))import inv # noqa: E402 必须在改完 sys.path 之后再导入注意import inv必须写在改sys.path之后。把导入写在文件顶部是更好的习惯但那种情况下改路径的语句就来不及生效了——这正是这类「延迟导入」写法存在的理由它不是为了炫技。生产环境正确做法是把包做成可安装的分发件配pyproject.toml用pip装进环境而不是靠脚本里改路径。常见坑点1. 用可变对象当 dataclass 字段默认值❌items: list []多个实例共享同一个列表一改全改。 ✅items: list field(default_factorylist)。2. 直接运行包内模块❌python inv/calc.py一遇到相对导入就报错。 ✅ 从包的父目录用python -m inv.calc或者干脆只运行包外的入口脚本。3. 把渲染函数当成字符串格式名传❌ 传fmtcsv却忘了在RENDERERS里注册KeyError 消息很难读。 ✅ 先if fmt not in RENDERERS判断再取抛出带上可选值的ValueError示例里就是这么做的。4. 子包__init__.py里提前导入重型模块❌ 在formats/__init__.py顶部无条件导入一个会读系统字体、加载大词库的模块结果只用纯文本输出的人也被拖慢。 ✅ 把重的东西放进函数内部按需导入或者分拆成多个子包。5. 累加逻辑写错导致数据被覆盖❌ 在add()里发现重名就直接return数量没累加。 ✅ 找到同名的就existing.quantity item.quantity再返回。6. 忘记if __name__ __main__:❌ 把main()的调用直接写在文件顶层别人import app时也跟着执行一遍演示数据打印。 ✅ 用if __name__ __main__:保护只在直接运行时执行。7. 模块名与标准库冲突❌ 把包里的工具模块命名为types.py、copy.py、queue.py导入标准库同名模块时被自己的文件截胡。 ✅ 改名加前缀如inv_types.py、inv_queue.py。8. 用 Python 2 的写法组织包❌ 沿用隐式相对导入、print语句、dict.iteritems()。Python 2.7 已于2020 年 1 月 1 日停止维护。 ✅ 一律用显式相对导入或绝对导入Python 3 语法。总结层次载体职责示例包inv/目录顶层命名空间与公开接口子包inv/formats/目录按功能再分一层模块models.py/calc.py单一职责的代码单元入口app.py编排与命令行启动包和模块的组织方式本质上是在回答「谁依赖谁」这个问题。把数据结构、计算逻辑、输出格式、入口脚本分成四层可以让依赖方向始终单向入口依赖一切格式和计算互不依赖模型谁都不依赖。这样以后加功能只是往对应模块里塞函数而不是回头改一堆 import。写完之后用python -m 包.模块而不是直接运行包内文件是保住这套结构不被破坏的最简单的一条纪律。