第10章 10.3 打包发布到 PyPI

上一章我们学会了用 pytest 给代码做"体检",让错误早早暴露。但你有没有想过:写出一个好用的工具后,怎么才能让同事、朋友、甚至全世界的开发者都能用上?

总不能让他们复制粘贴你的代码吧?这一章我们就来解决这个问题——把你的 Python 代码"打包"成可以一键安装的包,上传到 PyPI(Python 包仓库),让所有人用 pip install 就能用上你的作品。

🎯 开场:为什么要学打包发布?

场景来了: 你花了一周写了一个特别好用的数据处理函数,同事想要用,你该怎么分享?

方案 A:让他复制粘贴代码 → 他可能粘错、缺依赖、不知道咋用
方案 B:发一个压缩包 → 他得手动解压、放到正确位置、管理依赖
方案 C:让他运行 pip install your-package → 一行搞定!

这就是打包发布的魅力。说白了,它就是把代码"装进一个带说明书的盒子里",让别人开箱即用。

学完这章你能:
- 把自己的 Python 代码打包成可安装的 wheel 包
- 上传到 PyPI,让全世界的人都能用 pip 安装
- 理解版本号、依赖管理这些"行话"

你的学习路线:
1. 先搞懂 pyproject.toml 这个"包装盒设计图"
2. 用 build + twine 把包"封箱"并"寄出去"
3. 做 3 个实战项目,从简单到组合


🧱 基础:核心概念(25 分钟)

10.3.1 什么是 PyPI?

类比: PyPI 就像一个巨大的"Python 包超市"。当你运行 pip install requests 时,pip 就会去这个超市里找到 requests 包,下载并安装到你电脑上。

PyPI 的全称是 Python Package Index,它是 Python 官方维护的包仓库,目前托管了超过 40 万个包。

10.3.2 pyproject.toml —— 包装盒的设计图

写任何包之前,先要告诉"打包工具":这个包叫什么名字、版本多少、依赖什么、怎么运行?

这些信息都写在一个叫 pyproject.toml 的文件里。

最简单的 pyproject.toml 长这样:

[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"

[project]
name = "my-hello-package"
version = "0.1.0"
description = "我的第一个 Python 包"
requires-python = ">=3.8"
dependencies = []

逐行解释:
- [build-system]:告诉电脑用啥工具来打包(setuptools 是最常用的)
- name:包的名字,安装时会用 pip install my-hello-package
- version:版本号,每次发布新版本要改这个
- description:包的简介
- requires-python:支持哪些 Python 版本
- dependencies:你的包依赖哪些其他包(比如 requests 包就依赖 urllib3)

10.3.3 项目结构 —— 包要放在正确的位置

光有设计图还不够,你的代码要放在正确的文件夹结构里:

my-hello-package/
├── pyproject.toml          # 包装盒设计图
├── src/                    # 源代码放这里(src 是约定俗成)
│   └── my_package/         # 你的包名(注意是文件夹)
│       ├── __init__.py     # 必须有!告诉 Python 这是一个包
│       └── hello.py        # 你的代码文件
└── README.md               # 使用说明(可选但推荐)

注意! __init__.py 这个文件可以是空的,但必须有!它告诉 Python "这个文件夹是一个包,不是普通文件夹"。

10.3.4 版本号规范 —— 为什么要用语义化版本?

当你看到 requests 2.31.0 这种版本号,这是遵循语义化版本(Semantic Versioning)的规则:

主版本号.次版本号.修订号
2         31        0
  • 主版本号(2):做了不兼容的大改动,比如之前叫 requests 现在叫 requests2
  • 次版本号(31):新增了功能,但保持向后兼容
  • 修订号(0):修复了 bug,但没新增功能

类比: 就像电影版本——导演剪辑版(主版本变化)、加长版(次版本变化)、修复版(修订号变化)。

10.3.5 安装依赖 vs 发布依赖

你的包可能需要其他包来运行,这叫依赖。在 pyproject.toml 里有两种写法:

dependencies = ["requests>=2.28", "pandas>=1.5"]  # 运行必须有的依赖

如果你开发时用一些工具,但发布后不需要,可以用 optional-dependencies

[project.optional-dependencies]
dev = ["pytest>=7.0"]  # 开发时才用,发布后不需要

生活类比:
- dependencies = 做饭必须有的食材(米、油、盐)
- optional-dependencies = 做饭时用的工具(锅、铲子),做好了菜就不需要了

配图1 - 配图1


🔥 实战:3 个递进小项目(35 分钟)

📦 项目 1:发布一个最简单的包(5 分钟)

目标: 创建一个"打印问候语"的包,并打包发布到本地。

Step 1:创建项目结构

mkdir hello-pkg && cd hello-pkg
mkdir -p src/hello_pkg
touch src/hello_pkg/__init__.py

Step 2:写代码

# src/hello_pkg/__init__.py
def greet(name="World"):
"""向指定名字打招呼"""
return f"你好,{name}!欢迎使用 hello_pkg!"

Step 3:写 pyproject.toml

[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"

[project]
name = "hello-pkg"
version = "0.1.0"
description = "一个简单的问候语包"
requires-python = ">=3.8"
dependencies = []

Step 4:打包

pip install build twine   # 先安装打包工具(只需安装一次)

python -m build           # 执行打包

预期输出:

Successfully built hello-pkg
├── dist/
│   ├── hello_pkg-0.1.0-py3-none-any.whl    # wheel 包(可 pip 安装)
│   └── hello-pkg-0.1.0.tar.gz              # 源码包

Step 5:本地安装测试

pip install ./dist/hello_pkg-0.1.0-py3-none-any.whl

然后在 Python 里测试:

import hello_pkg
print(hello_pkg.greet("小明"))
# 输出:你好,小明!欢迎使用 hello_pkg!

一句话解释: 我们把代码"装进盒子里",生成了 .whl 文件,这就是可以 pip 安装的包。


📦 项目 2:发布一个处理 CSV 数据的包(15 分钟)

场景: 你写了一个分析考试成绩的函数,想分享给同事用。

项目结构:

grade-analyzer/
├── pyproject.toml
├── src/grade_analyzer/
│   ├── __init__.py
│   └── analyzer.py
├── tests/
│   └── test_analyzer.py
└── README.md

Step 1:analyzer.py 的代码

# src/grade_analyzer/analyzer.py
import csv
from pathlib import Path
from typing import List, Dict

def read_scores(csv_path: str) -> List[Dict[str, str]]:
"""读取 CSV 文件,返回成绩列表"""
scores = []
with open(csv_path, "r", encoding="utf-8") as f:
    reader = csv.DictReader(f)
    for row in reader:
        scores.append(row)
return scores

def calculate_average(scores: List[Dict[str, str]], subject: str) -> float:
"""计算某科目的平均分"""
total = 0
count = 0
for row in scores:
    if subject in row and row[subject]:
        total += float(row[subject])
        count += 1
return total / count if count > 0 else 0.0

def find_top_student(scores: List[Dict[str, str]], subject: str) -> str:
"""找出某科目最高分的学生"""
max_score = -1
top_student = ""
for row in scores:
    if subject in row and row[subject]:
        score = float(row[subject])
        if score > max_score:
            max_score = score
            top_student = row.get("name", "未知")
return top_student

Step 2:pyproject.toml

[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"

[project]
name = "grade-analyzer"
version = "0.1.0"
description = "分析学生成绩的工具包"
requires-python = ">=3.8"
dependencies = []

[project.scripts]
grade-analyzer = "grade_analyzer.cli:main"

Step 3:添加命令行入口(可选)

# src/grade_analyzer/cli.py
def main():
"""命令行入口"""
import sys
if len(sys.argv) < 2:
    print("用法:grade-analyzer <成绩.csv>")
    return

csv_path = sys.argv[1]
scores = __import__("grade_analyzer.analyzer", fromlist=["read_scores"]).read_scores(csv_path)

subjects = ["math", "english", "chinese"]

for subject in subjects:
    avg = __import__("grade_analyzer.analyzer", fromlist=["calculate_average"]).calculate_average(scores, subject)
    top = __import__("grade_analyzer.analyzer", fromlist=["find_top_student"]).find_top_student(scores, subject)
    print(f"{subject}: 平均分 {avg:.1f}, 最高分学生 {top}")

if __name__ == "__main__":
main()

Step 4:打包并安装

python -m build
pip install ./dist/grade-analyzer-0.1.0-py3-none-any.whl

Step 5:准备测试数据

# test_scores.csv
name,math,english,chinese
小明,85,90,88
小红,92,88,95
小李,78,85,80

运行测试:

grade-analyzer test_scores.csv

预期输出:

math: 平均分 85.0, 最高分学生 小红
english: 平均分 87.7, 最高分学生 小明
chinese: 平均分 87.7, 最高分学生 小红

一句话解释: 这个包可以读取 CSV 文件,分析成绩数据,找出平均分和最高分学生。

配图2 - 配图2


📦 项目 3:组合项目 —— 做一个"待办事项管理器"并发布(15 分钟)

场景: 把之前学的打包技术组合起来,做一个命令行待办事项工具,支持增删查,能持久化保存到本地 JSON 文件。

项目结构:

todo-manager/
├── pyproject.toml
├── src/todo_manager/
│   ├── __init__.py
│   ├── storage.py       # 负责读写 JSON 文件
│   ├── todo.py          # 待办事项逻辑
│   └── cli.py           # 命令行入口
├── tests/
│   └── test_todo.py
└── README.md

Step 1:storage.py —— 数据持久化

# src/todo_manager/storage.py
import json
from pathlib import Path
from typing import List, Dict

STORAGE_FILE = Path.home() / ".todo_manager" / "tasks.json"

def ensure_storage_dir():
"""确保存储目录存在"""
STORAGE_FILE.parent.mkdir(parents=True, exist_ok=True)

def load_tasks() -> List[Dict[str, str]]:
"""从文件加载任务"""
ensure_storage_dir()
if not STORAGE_FILE.exists():
    return []
with open(STORAGE_FILE, "r", encoding="utf-8") as f:
    return json.load(f)

def save_tasks(tasks: List[Dict[str, str]]):
"""保存任务到文件"""
ensure_storage_dir()
with open(STORAGE_FILE, "w", encoding="utf-8") as f:
    json.dump(tasks, f, ensure_ascii=False, indent=2)

Step 2:todo.py —— 核心逻辑

# src/todo_manager/todo.py
from typing import List, Dict, Optional
from .storage import load_tasks, save_tasks

def add_task(content: str) -> Dict[str, str]:
"""添加新任务"""
tasks = load_tasks()
task_id = len(tasks) + 1
task = {"id": task_id, "content": content, "done": False}
tasks.append(task)
save_tasks(tasks)
return task

def list_tasks() -> List[Dict[str, str]]:
"""列出所有任务"""
return load_tasks()

def done_task(task_id: int) -> Optional[Dict[str, str]]:
"""标记任务为完成"""
tasks = load_tasks()
for task in tasks:
    if task["id"] == task_id:
        task["done"] = True
        save_tasks(tasks)
        return task
return None

def delete_task(task_id: int) -> bool:
"""删除任务"""
tasks = load_tasks()
new_tasks = [t for t in tasks if t["id"] != task_id]
if len(new_tasks) == len(tasks):
    return False
save_tasks(new_tasks)
return True

Step 3:cli.py —— 命令行界面

# src/todo_manager/cli.py
import sys
from .todo import add_task, list_tasks, done_task, delete_task

def main():
"""命令行入口"""
if len(sys.argv) < 2:
    print("用法:todo <命令> [参数]")
    print("命令:")
    print("  todo add <内容>    - 添加任务")
    print("  todo list          - 列出所有任务")
    print("  todo done <id>     - 完成任务")
    print("  todo delete <id>   - 删除任务")
    return

command = sys.argv[1]

if command == "add" and len(sys.argv) >= 3:
    task = add_task(" ".join(sys.argv[2:]))
    print(f"✅ 已添加任务 #{task['id']}: {task['content']}")

elif command == "list":
    tasks = list_tasks()
    if not tasks:
        print("📝 没有待办事项")
    for task in tasks:
        status = "✓" if task["done"] else "○"
        print(f"{status} #{task['id']} {task['content']}")

elif command == "done" and len(sys.argv) >= 3:
    task_id = int(sys.argv[2])
    task = done_task(task_id)
    if task:
        print(f"✅ 已完成任务 #{task_id}")
    else:
        print(f"❌ 任务 #{task_id} 不存在")

elif command == "delete" and len(sys.argv) >= 3:
    task_id = int(sys.argv[2])
    if delete_task(task_id):
        print(f"🗑️ 已删除任务 #{task_id}")
    else:
        print(f"❌ 任务 #{task_id} 不存在")

else:
    print("❌ 命令不正确")

if __name__ == "__main__":
main()

Step 4:pyproject.toml

[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"

[project]
name = "todo-manager"
version = "0.1.0"
description = "命令行待办事项管理器"
requires-python = ">=3.8"
dependencies = []

[project.scripts]
todo = "todo_manager.cli:main"

Step 5:打包安装测试

python -m build
pip install ./dist/todo-manager-0.1.0-py3-none-any.whl

测试运行:

todo add "完成 Python 作业"
todo add "阅读《Python 编程》
todo list
todo done 1
todo list

预期输出:

✅ 已添加任务 #1: 完成 Python 作业
✅ 已添加任务 #2: 阅读《Python 编程》
○ #1 完成 Python 作业
○ #2 阅读《Python 编程》
✅ 已完成任务 #1
✓ #1 完成 Python 作业
○ #2 阅读《Python 编程》

一句话解释: 这个工具把待办事项存到用户主目录的 JSON 文件里,实现了数据的持久化保存。


💪 进阶:常见坑 + 调试技巧(20 分钟)

❌ 坑 1:包名和已存在的包重名

错误: 上传时提示 400 Package name 'requests' already exists

原因: PyPI 上已经有名为 requests 的包了,你不能用这个名字。

✅ 正确做法: 起一个独特的名字,比如 my-requests-tool 或者加入你的名字前缀 zhangsan-requests

❌ 坑 2:忘记创建 __init__.py

错误: ImportError: attempted relative import with no known parent package

原因: Python 把你的文件夹当成普通目录,而不是包。

✅ 正确做法: 确保每个包目录都有 __init__.py 文件,可以是空文件。

❌ 坑 3:版本号没改就重新打包

错误: pip 提示 Requirement already satisfied(不更新)

原因: 同一个版本号,pip 认为包没变化。

✅ 正确做法: 每次发布新版本前,先改 pyproject.toml 里的 version,比如从 0.1.0 改成 0.1.1

❌ 坑 4:依赖写漏了

错误: 用户安装后运行时提示 ModuleNotFoundError: No module named 'xxx'

原因: 你的代码依赖某个包,但 dependencies 里没写。

✅ 正确做法: 开发时就养成好习惯,把所有 import 的第三方包都加到 dependencies 里。

❌ 坑 5:在 Windows 上打包用了特殊字符

错误: 上传后其他平台安装失败

原因: Windows 默认编码和跨平台不兼容。

✅ 正确做法: 所有文件用 UTF-8 编码,打开文件时明确指定 encoding="utf-8"

💡 性能小贴士:wheel 包比源码包安装快

*.whl 是 wheel 包,它是预编译的,安装速度比 *.tar.gz 源码包快 3-5 倍。python -m build 会同时生成两种包,发布时推荐优先使用 wheel。

🔍 调试技巧:用 pip install -e . 进行开发调试

不想每次改代码都重新打包?用可编辑模式安装:

pip install -e .

这样你的包会被"软链接"到当前代码目录,改完代码直接就能测试,不用重新打包。


✏️ 练习题 + 作业题(7 分钟)

练习题(5 道,10 分钟内完成)

练习 1(1 分钟):版本号改改
- 输入:把项目 1 的版本号从 0.1.0 改成 0.2.0
- 预期输出:打包后生成的文件名包含 0.2.0
- 提示:只改 pyproject.toml 里的一行

练习 2(2 分钟):加个 if 判断
- 输入:在项目 1 的 greet() 函数里,如果 name 为空字符串,返回"请告诉我你的名字"
- 预期输出:greet("") 输出"请告诉我你的名字"
- 提示:在函数开头加个 if not name: 判断

练习 3(3 分钟):处理新数据
- 输入:用项目 2 的方法,创建一个新的 CSV 文件(水果销量表),分析平均销量和销量最高的水果
- 输入 CSV:fruit_sales.csv,包含 name, sales 两列,数据自拟
- 预期输出:打印每个水果的平均销量和销量冠军
- 提示:复制 analyzer.py 的结构,只改字段名

练习 4(4 分钟):串起两个项目
- 输入:把项目 2 的成绩分析功能和项目 3 的命令行入口组合,让 grade-analyzer 可以用 grade-analyzer analyze <csv> 命令运行
- 预期输出:命令行能正常执行并输出分析结果
- 提示:参考项目 3 的 cli.py 结构,在项目 2 里新建一个 cli.py

练习 5(5 分钟):分析报错
- 输入:用户运行 pip install my-package 报错 ERROR: Package 'my-package' does not exist
- 预期输出:你能说出至少 3 个可能的原因
- 提示:想想包名拼写、PyPI 是否已发布、是否用错了包名


📝 作业:做一个"单词背诵助手"并发布

需求描述:
做一个命令行背单词工具,用户可以添加单词、查看单词列表、随机抽查自己。

功能点:
1. word add <单词> <释义> - 添加新单词
2. word list - 查看所有单词
3. word quiz - 随机抽查一个单词的释义

加分项:
1. 用 JSON 文件持久化保存单词(存到 ~/.word_helper/words.json
2. 支持导入 CSV 格式的单词本

验收标准:
- 能 pip install 安装
- 命令行 word add/list/quiz 三个命令都能正常运行
- 数据能跨会话保存

提交方式: 评论区贴代码或 GitHub 链接


📚 总结 + 资源(5 分钟)

这章我们学了 3 个核心点:
1. pyproject.toml 是包的"设计图",定义了包名、版本、依赖
2. 打包流程:写代码 → 配置 pyproject.toml → python -m build → 生成 wheel 包
3. 版本号规范:遵循语义化版本,主版本、次版本、修订号各有含义

延伸学习资源:
- Python 官方打包指南 —— 最权威的教程
- setuptools 文档 —— 深入了解 pyproject.toml 的各种配置
- 《Python 工匠》 —— 里面有专门讲如何写优质开源项目的章节

互动钩子:
你写过 Python 包吗?发布到 PyPI 过程中踩过什么坑?评论区聊聊,帮你解答!


下章预告: 打包发布让你的代码变成了可复用的"产品"。但产品运行起来会不会很慢?下一章「性能分析与优化」,我们一起来给代码做"体检",找出性能瓶颈!

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。