附录 A Python 从零到能开发(写给 Android 开发者)
一句话总结
如果你会 Kotlin/Java 写 Android,那你已经会 90% 的编程了——你缺的不是"编程能力",而是三样具体的东西:Python 的语法长什么样(缩进代替大括号、动态类型、没有 new)、Python 世界的 Gradle 叫什么(答案是 uv + pyproject.toml)、以及一个 Python 应用是怎么从空目录长成能被别人 pip install 的东西、再变成一个跑在服务器上的容器的。这一章就把这三样按顺序补齐,全程只做一件事:从空目录敲出一个叫 notekit 的笔记工具,它同时是命令行程序和 Web API,带测试、带类型检查、能打包、能装到别人机器上、能塞进 Docker 跑起来。
本章你能学到什么
| # | 你会学到 | 学完能干什么 |
|---|---|---|
| 1 | 环境:为什么系统自带的 python3 不能用、uv 是什么、虚拟环境到底虚拟了什么 |
在任何一台新机器上 5 分钟搭好一个干净的 Python 项目 |
| 2 | 基础语法:动态类型、缩进、真值、切片、推导式、== vs is |
读懂任何一段 Python 代码,不再被"这行为什么不报错"卡住 |
| 3 | 函数与类:四种参数传法、self、魔术方法、@property、@dataclass、鸭子类型 |
把 Kotlin 的 data class / interface / companion object 一一翻译过来 |
| 4 | 异常 / 文件 / JSON / 日志:try/except/else/finally、pathlib、with、logging |
写出不会静默吞错、不会写死路径的代码 |
| 5 | 模块与包:import 到底干了什么、__init__.py、相对导入、if __name__ == "__main__" |
彻底搞定 ImportError,这是新手最大的一道坎 |
| 6 | 装饰器 / 生成器 / 闭包:@ 是语法糖、yield 是暂停键 |
看懂 FastAPI、pytest、LangChain 里满屏的 @ |
| 7 | 类型提示与 mypy:为什么写了类型还是不检查 | 用 mypy 把 Kotlin 编译器帮你抓的那类错重新抓回来 |
| 8 | 并发:GIL、线程、进程、async/await |
亲手测出"多线程为什么在 Python 里不加速",并知道三者各用在哪 |
| 9 | 主要库:pydantic / typer / rich / httpx / FastAPI / pytest / ruff | 拿到一个 Python 项目能看懂它的依赖表 |
| 10 | 项目实战:从空目录敲出 notekit(模型 → 存储 → CLI → HTTP API → 测试) |
独立写出一个结构正确、有测试、能交付的 Python 应用 |
| 11 | 发布:uv build 出 wheel、装进别人的环境、uv tool install 变成全局命令 |
把自己的工具发出去给别人用 |
| 12 | 部署:uvicorn --workers、多阶段 Dockerfile、compose、健康检查、数据卷 |
把 Web 服务以生产标准跑起来,不用 root、密钥不进镜像 |
| 13 | 调试与踩坑:读 Traceback、七种常见报错、28 条坑的总表 | 遇到报错能自己定位,而不是复制到搜索框 |
动手部分:16 个 ✅ Checkpoint,全部在本机真实跑过,每个 Checkpoint 里贴的输出都是实际跑出来的(包括那些故意让它报错的)。除第十二节的 Docker 部分需要装 Docker Desktop 外,全程离线、不需要任何 API Key。预计 2~3 天。
这一章和前十二章的关系:前面十二章教的是"智能体的原理",代码用的都是 Python;这一章补的是"你敲那些代码时用到的每一个 Python 知识点"。如果你在读前面章节时被某段语法卡住过,可以直接来这里按目录查。
先看一张全局对照表:你的 Android 知识怎么迁移
别从零开始学,从"翻译"开始学。 下面这张表里的每一行,后面都会展开。
| 你在 Android 里熟悉的 | Python 里对应的 | 关键区别(这是最容易踩的地方) |
|---|---|---|
| Java / Kotlin | Python 3.12 | 不编译,直接解释执行;写错类型要到运行那一行才炸 |
{ } 划分代码块 |
缩进(4 个空格) | 缩进错了就是语法错误,不是风格问题 |
String name = "x"; |
name = "x" |
不写类型、不写分号;同一个变量可以先存数字再存字符串 |
new Note(...) |
Note(...) |
没有 new |
this |
self |
必须显式写成方法的第一个参数 |
data class Note(...) |
@dataclass class Note: |
或者用 pydantic 的 BaseModel(多了运行时校验) |
interface Speaker |
Protocol / 直接鸭子类型 |
Python 里不实现接口也能用,只要方法名对得上 |
companion object 里的常量 |
类属性(写在 class 下、方法外) | 所有实例共享 |
getter/setter |
@property |
用起来像属性,实际是方法 |
Long(64 位,会溢出) |
int |
没有上限,永远不溢出 |
try/catch/finally |
try/except/finally |
多了一个 else(没出错才走) |
null |
None |
判断永远写 x is None,不写 x == None |
Gradle + build.gradle.kts |
uv + pyproject.toml |
概念几乎一一对应,见下一节 |
gradle/libs.versions.toml + gradle.lockfile |
uv.lock |
锁定精确版本 + 哈希 |
implementation / testImplementation |
dependencies / dependency-groups.dev |
一样是"运行时依赖"和"只在测试用的依赖" |
.aar / .apk |
.whl(wheel) |
wheel 就是一个 zip,改个后缀就能解开看 |
| Maven Central | PyPI (pypi.org) | 发布用 uv publish |
JUnit |
pytest | 断言直接写 assert,不用 assertEquals |
ktlint / detekt |
ruff | 一个工具同时干 lint + format,快到几乎无感 |
| Kotlin 编译器的类型检查 | mypy(要单独装、单独跑) | Python 运行时完全不检查类型,不跑 mypy 就等于没写 |
Application 类 / 四大组件 |
没有对应物 | Python 应用就是"一个入口函数",别找框架 |
AndroidManifest.xml |
[project.scripts] |
声明"哪个命令对应哪个函数" |
最重要的一行是倒数第三行:你在 Android 里靠编译器兜住的一大类错误(类型不匹配、拼错方法名),在 Python 里默认没人管。这不是 Python 差,而是这部分工作被移交给了 mypy 和测试。你必须把它们捡起来,否则会写出"跑一半才炸"的代码。这也是第 7 节和第 10.12 节存在的原因。
一、环境:先把地基打对(90% 的新手劝退都发生在这一步)
1.1 是什么:系统自带的 Python 为什么不能用
Mac 和 Linux 都自带 Python。但自带的那个版本通常很老。 在我这台机器上:
/usr/bin/python3 --version
输出:
Python 3.9.6
3.9 有多老?前面十二章里大量用到的写法它直接语法错误。比如 match 语句(相当于 Kotlin 的 when):
/usr/bin/python3 -c "
x=1
match x:
case 1: print('ok')
"
输出:
File "<string>", line 3
match x:
^
SyntaxError: invalid syntax
注意这是 SyntaxError,不是"功能不支持"——解释器连这段代码都读不懂。同类的还有 str | None(3.10+)、itertools.batched(3.12+)、def f[T](...) 泛型语法(3.12+)。
所以第一条规则:永远不要用系统自带的 python3 开发,也永远不要往它里面 pip install 任何东西(那是操作系统自己在用的 Python,装乱了会影响系统工具)。
1.2 是什么:虚拟环境到底"虚拟"了什么
Android 里每个项目的依赖是隔离的——A 项目用 OkHttp 4.9,B 项目用 4.12,互不影响,因为依赖是按项目下载到 Gradle 缓存再链进去的。
Python 早期不是这样:pip install 默认装到全局。于是 A 项目要 pydantic 1.x、B 项目要 pydantic 2.x 时就打起来了,这个现象有个专门的外号叫"依赖地狱"。
虚拟环境(virtual environment) 就是解决办法:在项目目录里建一个 .venv/ 文件夹,里面放一份独立的 Python 解释器(其实是符号链接)和一份独立的 site-packages(第三方包的存放处)。"激活"虚拟环境的本质,就是把 .venv/bin 插到 PATH 最前面,让你敲的 python 命中项目里的那个,而不是系统那个。
一句话类比:.venv/ 就是这个项目专属的 Gradle 缓存 + 专属的 JDK。
1.3 怎么用:uv —— Python 世界的 Gradle
历史上 Python 的工具链是碎的:pyenv 管版本、venv 建虚拟环境、pip 装包、pip-tools/poetry 锁版本、build/twine 打包发布。每个都要单独学。
uv(Astral 出的,用 Rust 写的)把这些全合成了一个命令,而且快得离谱(下面所有输出里的 "Installed 5 packages in 12ms" 都是真实耗时)。它的角色就是 Gradle:
| 你想干的事 | Gradle | uv |
|---|---|---|
| 装指定版本的语言运行时 | gradle-wrapper.properties 里定 JDK |
uv python install 3.12 / .python-version |
| 新建项目骨架 | Android Studio 模板 | uv init --package myapp |
| 加一个依赖 | 编辑 build.gradle.kts 再 sync |
uv add httpx |
| 加一个只在测试用的依赖 | testImplementation |
uv add --dev pytest |
| 按锁文件精确还原 | --offline + lockfile |
uv sync --frozen |
| 看依赖树 | gradle dependencies |
uv tree |
| 跑项目里的命令 | gradle run |
uv run <命令> |
| 打包 | assembleRelease → .apk/.aar |
uv build → .whl |
| 装一个全局 CLI 工具 | —— | uv tool install <包> |
安装 uv(只需一次,装完就有 uv 命令):
# macOS / Linux
curl -LsSf https://astral.sh/uv/install.sh | sh
# 或者用 Homebrew
brew install uv
安全提醒:
curl | sh这种写法是把网上的脚本直接交给 shell 执行。这里之所以可以接受,是因为 astral.sh 是 uv 官方域名、走 HTTPS。但请养成习惯:任何来源不明的curl | sh都不要执行,至少先curl -LsSf <url> -o install.sh下载下来看一眼。
验证:
uv --version
输出:
uv 0.11.32 (3010295ae 2026-07-23 aarch64-apple-darwin)
1.4 相关知识:uv run 和"激活虚拟环境"的关系
传统流程要三步:
python3 -m venv .venv
source .venv/bin/activate # 激活,提示符前面会多一个 (.venv)
pip install httpx
用 uv 的话,你几乎不需要手动激活。 uv run <命令> 会自动:确认 .venv 存在(不存在就建)→ 确认依赖和 uv.lock 一致(不一致就装)→ 在这个环境里执行命令。
uv run python -c "import sys; print(sys.executable)"
这条会打印出 .venv 里那个 python 的路径,而不是系统的。本章后面所有命令都用 uv run 前缀,这样你不会遇到"我明明装了包却 import 不到"这个经典问题。
一个真实会遇到的警告:如果你的 shell 里已经激活了别的虚拟环境(比如你在另一个项目的
.venv里),uv run会提示VIRTUAL_ENV=... does not match the project environment path .venv and will be ignored。它只是提醒,不影响结果;想让它消失就先deactivate,或者在命令前加unset VIRTUAL_ENV。
1.5 ✅ Checkpoint 1:环境跑通
uv python install 3.12
uv run --python 3.12 python -c "import sys; print(sys.version)"
uv run --python 3.12 python -c "import sys; print(sys.executable)"
我这里的真实输出:
3.12.13 (main, Mar 3 2026, 12:39:30) [Clang 21.0.0 (clang-2100.0.123.102)]
/private/tmp/verify/py/.venv/bin/python3
两点要确认:第一行是 3.12 而不是 3.9;第二行指向项目目录里的 .venv,不是 /usr/bin。只要这一步成功,后面所有代码都能跑。
(你的版本号可能是 3.12.x 的其它小版本,路径也会是你自己的目录,这都正常。)
二、基础语法:把 Kotlin 翻译成 Python
这一节请边看边敲。 新建一个文件 a_basics.py,一段一段加进去,每加一段就跑 uv run --python 3.12 python a_basics.py 看一眼。
2.1 缩进代替大括号
Python 用缩进表示代码块,冒号 : 表示"下面要缩进了"。
# Kotlin: if (x > 0) { println("正") } else { println("负") }
if x > 0:
print("正")
else:
print("负")
规则只有两条,但都是硬规则:同一个块里缩进必须完全一致(统一用 4 个空格,别用 Tab);缩进错了是 IndentationError,程序根本跑不起来。
好处是你再也不用为大括号放哪儿吵架了;代价是复制粘贴代码时要小心。
2.2 动态类型:变量是"标签"不是"盒子"
x = 10
print(type(x).__name__, x)
x = "十"
print(type(x).__name__, x)
输出:
int 10
str 十
在 Kotlin 里 var x: Int = 10 之后 x = "十" 是编译错误。Python 里完全合法。
心智模型的差别很重要:Kotlin 的变量像一个贴了类型标签的盒子,只能装那种东西;Python 的变量像一张便利贴,你可以把它从一个对象上撕下来贴到另一个对象上。类型属于对象,不属于变量名。
这带来的实际后果:拼错变量名、传错类型,都不会在"编译期"被发现(Python 没有编译期),只在执行到那一行时才炸。这就是为什么第 7 节的 mypy 是必需品而不是奢侈品。
2.3 数字:int 没有上限
big = 2 ** 200
print(big)
print("Kotlin 的 Long 最大值:", 2 ** 63 - 1)
输出:
1606938044258990275541962092341162602522202993782792835301376
Kotlin 的 Long 最大值: 9223372036854775807
Kotlin 的 Long 是 64 位,超了就静默溢出变成负数——这是金融、计数场景的经典 bug 来源。Python 的 int 会自动扩展成任意精度,永远不溢出(代价是大数运算比 C 慢,但正确性优先)。
除法和取模有两个坑:
print(7 / 2, 7 // 2, 7 % 2, 7 ** 2)
print(-7 // 2, -7 % 2) # 注意和 Java 不一样
输出:
3.5 3 1 49
-4 1
/永远返回浮点数,7 / 2是3.5不是3。要整除得用//。这和 Java 反过来,是从 Java 转过来最常见的第一个 bug。-7 // 2是 -4(向下取整,不是向零截断),-7 % 2是 1(结果符号跟除数)。Java 里分别是-3和-1。做取模索引(哈希分桶、环形缓冲)时这个差别会真的咬人。**是幂运算,没有Math.pow那么啰嗦。
2.4 字符串:f-string 是唯一你需要记的
name, score = "小林", 91.456
print(f"{name} 得了 {score:.1f} 分, 名字长度 {len(name)}")
print(f"{score=}")
输出:
小林 得了 91.5 分, 名字长度 2
score=91.456
f"..." 前面那个 f 表示"里面的 {} 要求值",和 Kotlin 的 "$name" 一个意思,但更强:{} 里可以写任意表达式({len(name)}),:.1f 是格式化(保留 1 位小数),{score=} 是调试神器——自动把变量名和值一起打出来。
字符串是不可变的,和 Kotlin 一样:
t = "abc"
print(t.upper(), t) # upper 返回新串,原串没变
print("拼接大量字符串要用 join:", "-".join(["a", "b", "c"]))
输出:
ABC abc
拼接大量字符串要用 join: a-b-c
在循环里用 += 拼几万次字符串会很慢(每次都新建一个),正确做法是先收集到列表再 "".join(...)——就是 Kotlin 里用 StringBuilder 的那个理由。
2.5 真值判断:空的东西都是假
这是 Python 和 Kotlin 差异最大、也最容易写出 bug 的地方。
for v in [0, 1, "", "a", [], [0], {}, None, 0.0]:
print(f" bool({v!r:>6}) = {bool(v)}")
输出:
bool( 0) = False
bool( 1) = True
bool( '') = False
bool( 'a') = True
bool( []) = False
bool( [0]) = True
bool( {}) = False
bool( None) = False
bool( 0.0) = False
规律:数字 0、空字符串、空列表、空字典、空元组、None 都是假,其它都是真。 注意 [0] 是真——它是"装了一个元素的列表",非空。
所以 Python 里判空可以直接写:
if not items: # 而不是 if len(items) == 0
print("空的")
但这也是个陷阱:if not count: 在 count == 0 时会进去,而你可能只想判断"没传值"。要区分"0"和"没有",必须写 if count is None:。 这个坑在第 10 节的 NoteUpdate 里会以真实形态出现。
2.6 == 和 is:值相等 vs 同一个对象
a = [1, 2]
b = [1, 2]
print("a == b:", a == b, " a is b:", a is b)
c = a
print("c is a:", c is a)
输出:
a == b: True a is b: False
c is a: True
== 比较内容(相当于 Kotlin 的 ==/equals),is 比较是不是同一个对象(相当于 Kotlin 的 ===)。
记住一条铁律:判断值用 ==,is 实践中只用来写 x is None。 为什么这么绝对?第 12 节有一个亲手跑出来的例子,会展示 is 在整数上的行为有多不可预测。
2.7 切片:比 substring 好用一百倍
s = "0123456789"
print(s[2:5], s[:3], s[-3:], s[::2], s[::-1])
nums = list(range(10))
print(nums[2:5], nums[::3])
输出:
234 012 789 02468 9876543210
[2, 3, 4] [0, 3, 6, 9]
格式是 [起点:终点:步长],含头不含尾(s[2:5] 取下标 2、3、4)。
- 省略起点 = 从头,省略终点 = 到尾
- 负数从右边数:
s[-3:]是最后三个 - 步长 2 = 隔一个取一个;步长 -1 = 反转(
s[::-1]是最常见的反转写法) - 列表和字符串通用;列表的
x[:]会生成一个浅拷贝,第 12 节会用到
2.8 推导式:一行搞定 map/filter
squares = [n * n for n in range(6)]
evens = [n for n in range(10) if n % 2 == 0]
table = {n: n * n for n in range(4)}
uniq = {c for c in "hello"}
print(squares)
print(evens)
print(table)
print(sorted(uniq))
输出:
[0, 1, 4, 9, 16, 25]
[0, 2, 4, 6, 8]
{0: 0, 1: 1, 2: 4, 3: 9}
['e', 'h', 'l', 'o']
读法是从中间往两边读:先看 for n in range(6)(对谁循环),再看 if ...(筛掉谁),最后看最前面的表达式(每个变成什么)。对照 Kotlin:
// Kotlin
val evens = (0 until 10).filter { it % 2 == 0 }
val squares = (0 until 6).map { it * it }
# Python:一个语法覆盖 map + filter
evens = [n for n in range(10) if n % 2 == 0]
squares = [n * n for n in range(6)]
四种括号对应四种结果:[...] 列表、{k: v ...} 字典、{...} 集合、(...) 生成器(第 6 节讲,行为完全不同)。
别嵌套超过两层——写得爽读着痛苦,超过两层就改成普通 for 循环。
2.9 元组:不可变的列表
point = (3, 4)
px, py = point
print(px, py)
try:
point[0] = 9
except TypeError as e:
print("改元组报错:", e)
输出:
3 4
改元组报错: 'tuple' object does not support item assignment
列表 [] 可改,元组 () 不可改。 元组的两个主要用途:一是当"轻量数据结构"(坐标、键值对);二是多返回值——return a, b 其实就是返回一个元组,接收时 x, y = f() 叫解包。这就是 Python 不需要 Kotlin Pair/Triple 的原因。
2.10 最经典的坑:可变默认参数
这一条独立出来,因为它是 Python 最著名的陷阱,而且 Kotlin 里不存在对应现象。
def bad(item, box=[]):
box.append(item)
return box
print(bad("a"), bad("b"), bad("c"))
def good(item, box=None):
if box is None:
box = []
box.append(item)
return box
print(good("a"), good("b"), good("c"))
输出:
['a', 'b', 'c'] ['a', 'b', 'c'] ['a', 'b', 'c']
['a'] ['b'] ['c']
看第一行:三次独立调用,结果互相污染了。原因是默认值只在函数「定义」的那一刻求值一次,那个 [] 从此成为函数对象的一个属性,所有调用共用它。
规则:默认值只能写不可变的东西(数字、字符串、None、元组)。要可变默认值,一律写 None + 函数内部判断。 第 10 节的 pydantic 里有对应的官方解法(default_factory)。
2.11 ✅ Checkpoint 2:基础语法全跑通
把上面所有片段按顺序拼成 a_basics.py(我这份共 84 行),然后:
uv run --python 3.12 python a_basics.py
完整真实输出:
--- 1. 动态类型 ---
int 10
str 十
--- 2. int 没有上限 ---
1606938044258990275541962092341162602522202993782792835301376
Kotlin 的 Long 最大值: 9223372036854775807
--- 3. 整除 / 取模 / 幂 ---
3.5 3 1 49
-4 1
--- 4. f-string ---
小林 得了 91.5 分, 名字长度 2
score=91.456
--- 5. 真值判断 ---
bool( 0) = False
bool( 1) = True
bool( '') = False
bool( 'a') = True
bool( []) = False
bool( [0]) = True
bool( {}) = False
bool( None) = False
bool( 0.0) = False
--- 6. == vs is ---
a == b: True a is b: False
c is a: True
--- 7. 切片 ---
234 012 789 02468 9876543210
[2, 3, 4] [0, 3, 6, 9]
--- 8. 推导式 ---
[0, 1, 4, 9, 16, 25]
[0, 2, 4, 6, 8]
{0: 0, 1: 1, 2: 4, 3: 9}
['e', 'h', 'l', 'o']
--- 9. 元组不可变 / 解包 ---
3 4
改元组报错: 'tuple' object does not support item assignment
--- 10. 可变默认参数陷阱 ---
['a', 'b', 'c'] ['a', 'b', 'c'] ['a', 'b', 'c']
['a'] ['b'] ['c']
--- 11. 字符串不可变 ---
ABC abc
拼接大量字符串要用 join: a-b-c
自查:第 10 节那两行必须一行污染、一行干净。如果你的两行一模一样,说明 good 里的 if box is None 写漏了。
三、函数与类:Kotlin 的每个特性都有对应物
新建 b_func_class.py,同样边加边跑。
3.1 参数的四种传法
Python 的函数签名比 Kotlin 灵活得多,一次讲清四种:
def order(dish, size="中", *extras, spicy=False, **options):
print(f" dish={dish} size={size} extras={extras} spicy={spicy} options={options}")
order("拉面")
order("拉面", "大")
order("拉面", "大", "加蛋", "加肉")
order("拉面", spicy=True)
order("拉面", "小", "加蛋", spicy=True, note="不要葱", to_go=True)
输出:
dish=拉面 size=中 extras=() spicy=False options={}
dish=拉面 size=大 extras=() spicy=False options={}
dish=拉面 size=大 extras=('加蛋', '加肉') spicy=False options={}
dish=拉面 size=中 extras=() spicy=True options={}
dish=拉面 size=小 extras=('加蛋',) spicy=True options={'note': '不要葱', 'to_go': True}
逐个解释:
| 写法 | 叫什么 | Kotlin 对应 | 说明 |
|---|---|---|---|
dish |
位置参数 | 普通参数 | 必须传 |
size="中" |
默认参数 | size: String = "中" |
一样 |
*extras |
可变位置参数 | vararg extras |
收进一个元组 |
spicy=False(在 * 后面) |
仅关键字参数 | 无对应 | 只能写 spicy=True,不能按位置传 |
**options |
可变关键字参数 | 无对应 | 收进一个字典 |
*args 和 **kwargs 这两个名字你会在所有 Python 库里见到(约定俗成,名字可以换但没人换)。它们最大的用途是转发——第 6 节的装饰器全靠这个。
* 后面的参数强制用名字传,这是个好设计:create_user(name, admin=True) 比 create_user(name, True) 可读得多。你可以主动加一个裸 * 来强制这一点:def f(a, *, b)。
3.2 多返回值就是元组
def divide(a, b):
return a // b, a % b
q, r = divide(17, 5)
print(q, r, type(divide(17, 5)).__name__)
输出:
3 2 tuple
不需要 Pair,不需要定义 data class。但超过 3 个返回值时,请改用 @dataclass 或 NamedTuple——不然调用方要数位置,很容易搞错顺序。
3.3 类型提示:写了不等于生效
def greet(name: str, times: int = 1) -> str:
return (f"你好, {name}! " * times).strip()
print(greet("小林", 2))
print("类型提示不会拦住你:", greet(123, 1)) # 运行期完全不检查
print("函数的注解:", greet.__annotations__)
输出:
你好, 小林! 你好, 小林!
类型提示不会拦住你: 你好, 123!
函数的注解: {'name': <class 'str'>, 'times': <class 'int'>, 'return': <class 'str'>}
请把第二行盯上五秒:我们声明了 name: str,却传了整数 123,Python 照跑不误。
这是从 Kotlin 转过来最需要重建的认知:Python 的类型提示(type hints)只是注解,运行时完全不检查。 它的价值在于:给 IDE 做补全和跳转、给 mypy 做静态检查、给读代码的人当文档、给 pydantic/FastAPI 这类库当元数据(它们会在运行时读 __annotations__ 来生成校验逻辑——这是第 10 节的关键机制)。
第 7 节会用 mypy 把这类错误抓出来。
3.4 类:self 必须自己写
class Note:
"""一条笔记。"""
count = 0 # 类属性:所有实例共享(相当于 companion object 里的变量)
def __init__(self, title: str, body: str = "", tags: list | None = None):
self.title = title # 实例属性,必须显式写 self
self.body = body
self.tags = tags or []
Note.count += 1
def add_tag(self, tag: str) -> "Note":
self.tags.append(tag)
return self # 返回 self 就能链式调用
def __repr__(self) -> str: # 相当于 Kotlin data class 自带的 toString()
return f"Note(title={self.title!r}, tags={self.tags})"
def __len__(self) -> int: # 让 len(note) 能用
return len(self.body)
def __eq__(self, other) -> bool:
return isinstance(other, Note) and self.title == other.title
@property
def summary(self) -> str: # 像属性一样访问的方法
return self.body[:5] + ("..." if len(self.body) > 5 else "")
@staticmethod
def help() -> str:
return "Note 用来存一条笔记"
@classmethod
def from_line(cls, line: str) -> "Note":
title, _, body = line.partition(":")
return cls(title.strip(), body.strip())
用起来:
n = Note("Python 入门", "先学语法再学库")
n.add_tag("python").add_tag("笔记")
print(n)
print("len(n) =", len(n))
print("n.summary =", n.summary)
print("Note.count =", Note.count)
print("Note.help() =", Note.help())
n2 = Note.from_line("Kotlin: 和 Python 的对比")
print("from_line ->", n2)
print("Note.count =", Note.count)
print("标题相同就相等:", Note("A") == Note("A"))
输出:
Note(title='Python 入门', tags=['python', '笔记'])
len(n) = 7
n.summary = 先学语法再...
Note.count = 1
Note.help() = Note 用来存一条笔记
from_line -> Note(title='Kotlin', tags=[])
Note.count = 2
标题相同就相等: True
七个要点,逐条对照 Kotlin:
__init__是构造函数,self是this但必须显式写成第一个参数。写方法时忘了self是新手最高频的错误,报错长这样:TypeError: add_tag() takes 1 positional argument but 2 were given。- 实例属性必须写
self.x = ...,没有"声明区"。不写self.就只是个局部变量,方法一结束就没了。 - 类属性(
count = 0)写在 class 下、方法外,所有实例共享——就是companion object里的变量。注意Note.count += 1要用类名,写self.count += 1会创建一个同名的实例属性把类属性遮住(这个坑很隐蔽)。 - 双下划线开头结尾的是"魔术方法"(dunder method),是 Python 的运算符重载机制:
__repr__对应toString()、__len__让len(x)能用、__eq__让==能用。注意!r在 f-string 里表示"用repr()而不是str()",所以字符串会带引号。 @property让方法可以像属性一样访问(n.summary不带括号)——就是 Kotlin 的自定义 getter。@staticmethod不需要self,就是个挂在类里的普通函数。@classmethod第一个参数是cls(类本身),主要用来写替代构造函数——Note.from_line(...)就是 Kotlin 里companion object { fun fromLine(...) }的标准做法。
没有
private。Python 靠约定:单下划线_x表示"内部用的,别碰"(工具和人都会尊重它,但语言不强制);双下划线__x会触发名字改写(name mangling),有点像 private 但主要目的是避免子类冲突,不是安全机制。第 10 节的NoteStore._load()就是这个约定的实际用法。
3.5 继承
class TodoNote(Note):
def __init__(self, title: str, body: str = "", done: bool = False):
super().__init__(title, body)
self.done = done
def __repr__(self) -> str:
mark = "x" if self.done else " "
return f"[{mark}] {self.title}"
t = TodoNote("买菜", done=True)
print(t, "| isinstance(t, Note) =", isinstance(t, Note))
输出:
[x] 买菜 | isinstance(t, Note) = True
class 子类(父类): 就是继承,super().__init__(...) 调父类构造。和 Kotlin 最大的区别:Python 的类默认就能被继承、方法默认就能被覆盖,不需要 open。另外 Python 支持多继承(class C(A, B)),但初学阶段请避免——方法解析顺序(MRO)的规则很绕,实际项目里用组合更好。
x if cond else y 是三元表达式,相当于 Kotlin 的 if (cond) x else y。
3.6 @dataclass:Kotlin data class 的直接对应
from dataclasses import dataclass, field
@dataclass
class Config:
model: str = "deepseek-chat"
temperature: float = 0.7
tags: list = field(default_factory=list) # 可变默认值必须用 default_factory
c1 = Config()
c2 = Config()
c1.tags.append("a")
print(c1)
print("两个实例的 tags 是不是同一个:", c1.tags is c2.tags)
print("dataclass 自带 ==:", Config(model="x") == Config(model="x"))
输出:
Config(model='deepseek-chat', temperature=0.7, tags=['a'])
两个实例的 tags 是不是同一个: False
dataclass 自带 ==: True
@dataclass 会自动生成 __init__、__repr__、__eq__——就是 data class 干的事。
注意第三个字段:可变默认值不能写 tags: list = [](那就是 2.10 的坑),必须写 field(default_factory=list),意思是"每次创建实例时调用一次 list() 生成新的"。输出第二行 False 就是这个机制生效的证据。
@dataclass vs pydantic BaseModel 怎么选:纯内部数据结构用 @dataclass(标准库自带、零开销);数据来自外部(HTTP 请求、配置文件、LLM 返回)用 pydantic(会真的校验和转换)。第 10 节会展示 pydantic 那一半。
3.7 鸭子类型:不实现接口也能用
class FakeFile:
def write(self, s):
print(" FakeFile 收到:", s.strip())
def dump(f):
f.write("hello\n")
dump(FakeFile()) # 不需要实现任何接口,有 write 方法就行
输出:
FakeFile 收到: hello
"如果它走起来像鸭子、叫起来像鸭子,那它就是鸭子"——Python 不检查你实现了什么接口,只在调用的那一刻看有没有这个方法。
这对写测试是巨大的便利:想 mock 一个文件对象,不用继承任何东西,随手写个有 write 方法的类就行,也不需要 Mockito。代价是"接口"只存在于文档和你的脑子里,写错了要运行时才发现——第 7 节的 Protocol 就是把这个契约重新写下来给 mypy 检查的办法。
3.8 ✅ Checkpoint 3:函数与类
b_func_class.py 完整跑一遍(我这份 139 行):
uv run --python 3.12 python b_func_class.py
真实输出:
--- 1. 参数四种传法 ---
dish=拉面 size=中 extras=() spicy=False options={}
dish=拉面 size=大 extras=() spicy=False options={}
dish=拉面 size=大 extras=('加蛋', '加肉') spicy=False options={}
dish=拉面 size=中 extras=() spicy=True options={}
dish=拉面 size=小 extras=('加蛋',) spicy=True options={'note': '不要葱', 'to_go': True}
--- 2. 多返回值 ---
3 2 tuple
--- 3. 类型提示 ---
你好, 小林! 你好, 小林!
类型提示不会拦住你: 你好, 123!
函数的注解: {'name': <class 'str'>, 'times': <class 'int'>, 'return': <class 'str'>}
--- 4. 类 ---
Note(title='Python 入门', tags=['python', '笔记'])
len(n) = 7
n.summary = 先学语法再...
Note.count = 1
Note.help() = Note 用来存一条笔记
from_line -> Note(title='Kotlin', tags=[])
Note.count = 2
标题相同就相等: True
--- 5. 继承 ---
[x] 买菜 | isinstance(t, Note) = True
--- 6. dataclass ---
Config(model='deepseek-chat', temperature=0.7, tags=['a'])
两个实例的 tags 是不是同一个: False
dataclass 自带 ==: True
--- 7. 鸭子类型 ---
FakeFile 收到: hello
自查两处:类型提示不会拦住你: 你好, 123! 必须出现(证明类型不被强制);两个实例的 tags 是不是同一个: False(证明 default_factory 生效)。
四、异常、文件、JSON、日志:写出不会静默出错的代码
新建 c_io_json.py。
4.1 异常:多了一个 else
def parse_age(text):
try:
age = int(text)
except ValueError as e:
print(f" 转不了数字({e!r}) -> 返回 None")
return None
except (TypeError, KeyError):
print(" 可以一次抓多种")
return None
else:
print(" 没出错才会走 else")
return age
finally:
print(" finally 一定执行(不管 return 还是抛错)")
print("结果:", parse_age("28"))
print("结果:", parse_age("二十八"))
输出:
没出错才会走 else
finally 一定执行(不管 return 还是抛错)
结果: 28
转不了数字(ValueError("invalid literal for int() with base 10: '二十八'")) -> 返回 None
finally 一定执行(不管 return 还是抛错)
结果: None
四段的职责:
| 段 | 什么时候执行 | Kotlin 对应 |
|---|---|---|
try |
可能出错的代码 | try |
except X as e |
出了这类错 | catch (e: X) |
else |
一个错都没出时 | 无对应 |
finally |
无论如何都执行 | finally |
else 是 Python 特有的,价值在于把"不该被保护的代码"挪出 try。如果把 return age 写在 try 里,而 age 的后续处理又抛了 ValueError,就会被上面的 except 误抓。try 块越小越好,这是异常处理的第一原则。
except (TypeError, KeyError) 用元组一次抓多种。
4.2 自定义异常与异常链
class ToolError(Exception):
"""业务自己的异常类型,只要继承 Exception。"""
def call_tool(name):
try:
return {"get_weather": "晴"}[name]
except KeyError as e:
raise ToolError(f"没有名为「{name}」的工具") from e
try:
call_tool("get_wether") # 故意拼错
except ToolError as e:
print(" 捕获:", e)
print(" 原始原因:", repr(e.__cause__))
输出:
捕获: 没有名为「get_wether」的工具
原始原因: KeyError('get_wether')
定义异常只要 class XxxError(Exception): 加一行文档字符串就够了(类体不能为空,用文档字符串或 pass 占位)。
raise ... from e 是重点:它把底层异常挂在新异常的 __cause__ 上。这样上层拿到的是业务语义的错误("没有这个工具"),但排查时仍能顺着链条找到根因(KeyError)。打印 traceback 时会显示 "The above exception was the direct cause of..."。前面章节里包装 LLM 调用、工具调用的异常,都该这么写。
4.3 千万别写裸 except
try:
try:
raise KeyboardInterrupt("用户按了 Ctrl+C")
except Exception as e: # 正确:Exception 抓不到 KeyboardInterrupt
print(" 不该走到这里")
except KeyboardInterrupt:
print(" KeyboardInterrupt 逃出了 except Exception —— 这是对的")
输出:
KeyboardInterrupt 逃出了 except Exception —— 这是对的
Python 的异常有两条继承线:Exception(业务错误)和 BaseException(包含 KeyboardInterrupt、SystemExit 这些"控制流"信号)。
except Exception:—— 抓业务错误,不会拦住 Ctrl+C。这是你 99% 的场合该写的。except:或except BaseException:—— 连 Ctrl+C 都吞掉,程序变成杀不死的僵尸。永远不要写。
上面的实验就是证据:except Exception 没有拦住 KeyboardInterrupt,它成功逃到了外层。
另一条铁律:except 里绝不能什么都不做。 except Exception: pass 是"静默吞错",是最难排查的 bug 来源。至少 log.exception(...) 一下。
4.4 pathlib:别再手拼路径
from pathlib import Path
work = Path("workspace")
work.mkdir(exist_ok=True)
f = work / "notes.txt" # 用 / 拼路径,跨平台
f.write_text("第一行\n第二行\n", encoding="utf-8")
print(" 存在吗:", f.exists(), "| 大小:", f.stat().st_size, "字节")
print(" 文件名:", f.name, "| 后缀:", f.suffix, "| 父目录:", f.parent)
print(" 读回来:", repr(f.read_text(encoding="utf-8")))
print(" 目录里有:", sorted(p.name for p in work.iterdir()))
输出:
存在吗: True | 大小: 20 字节
文件名: notes.txt | 后缀: .txt | 父目录: workspace
读回来: '第一行\n第二行\n'
目录里有: ['data.json', 'notes.txt']
Path 用 / 运算符拼路径(重载了除号),跨平台自动处理分隔符——相当于 Kotlin 里 File(parent, child) 但顺手得多。
三个要点:mkdir(exist_ok=True) 目录已存在时不报错(还有 parents=True 递归创建);write_text/read_text 一行完成读写,省掉 open + with;永远显式写 encoding="utf-8"——不写就用系统默认编码,在中文 Windows 上是 GBK,读中文文件必乱码。这是跨平台协作最常见的一个坑。
4.5 with:自动关资源
with open(f, "a", encoding="utf-8") as fp:
fp.write("第三行\n")
with open(f, encoding="utf-8") as fp:
for i, line in enumerate(fp, start=1):
print(f" {i}: {line.rstrip()}")
输出:
1: 第一行
2: 第二行
3: 第三行
with 是 Kotlin 的 use { }:块结束时自动调用清理逻辑,就算中途抛异常也会执行。文件、数据库连接、HTTP 客户端、锁,全都该用 with。
打开模式:"r" 读(默认)、"w" 写(会清空原文件)、"a" 追加、"rb"/"wb" 二进制。
直接对文件对象做 for 循环是逐行迭代,而且是惰性的——读 10GB 日志也不会爆内存,这比 read().split("\n") 好得多。
自己写一个 with 能用的类,只要两个魔术方法:
class Timer:
def __enter__(self):
print(" [Timer] 开始")
return self
def __exit__(self, exc_type, exc_val, tb):
print(f" [Timer] 结束 (异常类型={exc_type})")
return False # 返回 False = 不吞掉异常
with Timer():
print(" 干活中...")
输出:
[Timer] 开始
干活中...
[Timer] 结束 (异常类型=None)
__exit__ 的三个参数是异常信息(没异常时全是 None);返回 False 表示"我不处理,让异常继续往上抛"——除非你真的想吞掉异常,否则永远返回 False 或者干脆不返回(默认就是 None,等效于 False)。
4.6 JSON:和 Python 类型的对应关系
import json
data = {"name": "小林", "tags": ["python", "agent"], "score": 91.5, "ok": True,
"extra": None}
text = json.dumps(data, ensure_ascii=False, indent=2)
print(text)
back = json.loads(text)
print(" 往返后相等:", back == data)
print(" Python None <-> JSON null:", json.dumps({"a": None}))
print(" Python True <-> JSON true:", json.dumps({"a": True}))
输出:
{
"name": "小林",
"tags": [
"python",
"agent"
],
"score": 91.5,
"ok": true,
"extra": null
}
往返后相等: True
Python None <-> JSON null: {"a": null}
Python True <-> JSON true: {"a": true}
dumps = 对象 → 字符串(dump string),loads = 字符串 → 对象。带文件版本是 dump(obj, fp) / load(fp)(没有 s)。
ensure_ascii=False 必须写,否则中文会被转义成 \uXXXX。实测对比:
print(json.dumps({"name": "小林"}))
print(json.dumps({"name": "小林"}, ensure_ascii=False))
{"name": "\u5c0f\u6797"}
{"name": "小林"}
两者都是合法 JSON、解析回来一模一样,但第一种人没法读,日志和数据文件里全是这种东西会让排查变成噩梦。indent=2 是美化缩进,存文件时加上,网络传输时省掉。
对应关系要记住:None ↔ null、True ↔ true(Python 大写、JSON 小写)、dict ↔ object、list ↔ array。元组会被转成 array,但转回来变成 list,不是无损往返。
JSON 比你想的严格:
try:
json.loads("{'name': '小林'}") # 单引号不是合法 JSON
except json.JSONDecodeError as e:
print(" 单引号解析失败:", e)
输出:
单引号解析失败: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)
JSON 只认双引号,且不允许尾随逗号。 这在处理 LLM 输出时是高频事故——模型很爱吐单引号或者带尾逗号的"JSON"。前面章节里用 ast.literal_eval 兜底解析、以及严格要求模型输出格式,就是为了对付这个。
4.7 logging:别用 print 调试
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-7s [%(name)s] %(message)s",
datefmt="%H:%M:%S",
)
log = logging.getLogger("notekit")
log.debug("这条看不见,因为级别是 INFO")
log.info("加载了 %d 条笔记", 3)
log.warning("磁盘快满了")
try:
1 / 0
except ZeroDivisionError:
log.exception("算错了") # 自动带上堆栈
输出(这几行是写到 stderr 的):
17:49:28 INFO [notekit] 加载了 3 条笔记
17:49:28 WARNING [notekit] 磁盘快满了
17:49:28 ERROR [notekit] 算错了
Traceback (most recent call last):
File "/private/tmp/verify/py/c_io_json.py", line 124, in <module>
1 / 0
~~^~~
ZeroDivisionError: division by zero
五个级别从低到高:DEBUG → INFO → WARNING → ERROR → CRITICAL。level=logging.INFO 意思是"INFO 及以上才输出",所以 log.debug(...) 那行看不见——这就是 logging 相对 print 的核心优势:一个开关控制所有输出的详细程度,上线改成 WARNING 就安静了,出问题改成 DEBUG 就啥都看得见。
三个细节:
- 用
log.info("加载了 %d 条", 3)而不是 f-string。这种"延迟格式化"在日志级别不够时根本不会做字符串拼接,省性能;而且日志聚合系统能按模板归类。 log.exception(...)只能在except块里用,它会自动附上完整堆栈——比log.error(str(e))有用一百倍,后者丢掉了出错位置。getLogger("名字")按模块取 logger,标准做法是每个文件顶部写log = logging.getLogger(__name__),这样日志里自动带上模块名,能按模块单独调级别。
一个观察输出时的注意点:上面这些日志行在终端里是和
> out.txt),日志会消失——因为它默认走 stderr,而 print 走 stdout。要一起收集得写> out.txt 2>&1。同样,你看到的日志行和 print 行的先后顺序在重定向后可能对不上,这是两个流各自缓冲导致的,不是你的代码有问题。
4.8 ✅ Checkpoint 4:异常 / 文件 / JSON / 日志
uv run --python 3.12 python c_io_json.py
除上面已贴的各段外,你还会看到最后两行:
--- 8. __name__ ---
当前 __name__ = __main__
这就是 if __name__ == '__main__' 的原理:被 import 时它等于模块名
这是下一节的引子。自查:workspace/ 目录被创建了,里面有 notes.txt(3 行)和 data.json。
五、模块与包:新手最大的一道坎
如果你只读这一章的一节,读这节。 ImportError / ModuleNotFoundError 是 Python 新手 90% 的挫败感来源,而它的规则其实只有三条。
5.1 是什么:import 到底干了什么
Android 里 import 是给编译器看的,纯粹是"写全类名太长"的简写。Python 的 import 是一条真正执行的语句,它做三件事:
- 找:在
sys.path列出的目录里按名字找.py文件或包目录 - 执行:把那个文件从头到尾跑一遍(所以文件里的
print会打印出来) - 缓存:把结果记进
sys.modules,同一个模块第二次 import 不会再执行
建一个目录来验证。文件结构:
impdemo/
├── main.py
└── pkg/
├── __init__.py
├── tools.py
└── sub/
└── deep.py
pkg/__init__.py:
print(" [import] pkg/__init__.py 被执行了")
VERSION = "1.0"
pkg/tools.py:
print(" [import] pkg/tools.py 被执行了")
def greet(name):
return f"你好 {name}"
if __name__ == "__main__":
print("直接运行 tools.py 时才会打印这句")
pkg/sub/deep.py:
from ..tools import greet # 相对导入:.. 表示上一层包
def shout(name):
return greet(name).upper()
main.py:
import sys
print("1) 模块搜索路径的第一项(脚本所在目录):", sys.path[0])
print("2) 第一次 import:")
import pkg.tools
print("3) 第二次 import(不会再执行一遍):")
import pkg.tools
print("4) 调用:", pkg.tools.greet("小林"))
from pkg.sub.deep import shout
print("5) 相对导入也能用:", shout("小林"))
print("6) pkg 的属性:", pkg.VERSION)
print("7) 已加载的 pkg 相关模块:", sorted(m for m in sys.modules if m.startswith("pkg")))
跑:
uv run --python 3.12 python impdemo/main.py
真实输出:
1) 模块搜索路径的第一项(脚本所在目录): /private/tmp/verify/py/impdemo
2) 第一次 import:
[import] pkg/__init__.py 被执行了
[import] pkg/tools.py 被执行了
3) 第二次 import(不会再执行一遍):
4) 调用: 你好 小林
5) 相对导入也能用: 你好 小林
6) pkg 的属性: 1.0
7) 已加载的 pkg 相关模块: ['pkg', 'pkg.sub', 'pkg.sub.deep', 'pkg.tools']
这个输出里有五个关键信息:
sys.path[0]是"被运行的那个脚本所在的目录"——这是理解所有 import 问题的钥匙。不是你敲命令时所在的目录,是脚本文件所在的目录。- import
pkg.tools会先执行pkg/__init__.py(两行 print 的顺序就是证据)。所以__init__.py里别写耗时的东西。 - 第 3 步什么都没打印——第二次 import 直接从
sys.modules拿缓存。这意味着模块顶层代码在一次进程里只执行一次,你可以放心地在多个文件里 import 同一个模块。 pkg.VERSION能访问:__init__.py里定义的东西就是"包本身"的属性。- 第 7 行显示
pkg.sub也被加载了——导入子模块会连带导入所有父包。
5.2 怎么用:三条规则
规则一:__init__.py 标记"这是一个包"。
它可以是空文件(现代 Python 里包目录没有它也能被识别,叫"命名空间包",但别依赖这个特性)。它的正经用途是对外暴露简洁的 API:
# pkg/__init__.py
from .tools import greet # 这样外面可以写 from pkg import greet
第 10 节的 notekit/__init__.py 就是这么用的。
规则二:绝对导入用在跨包,相对导入用在包内部。
from pkg.tools import greet # 绝对导入:从根开始写全路径
from .tools import greet # 相对导入:. 是当前包,.. 是上一层
包内部的互相引用一律用相对导入(. 开头)。理由:包被改名或移动时不用改代码。第 10 节的 notekit 里所有内部引用都是 from .models import Note 这种形式。
规则三:if __name__ == "__main__": 是"这个文件是被直接运行的吗"。
每个模块都有一个 __name__ 变量:被直接运行时它是字符串 "__main__",被 import 时它是模块名(比如 "pkg.tools")。所以:
if __name__ == "__main__":
main() # 只在直接运行时执行,被 import 时不执行
这不是仪式,是必需的:不写的话,别人 import 你的模块会意外触发你的主流程。第 8 节还会看到,多进程场景下不写这句会导致代码被重复执行。
5.3 踩坑:三种 ImportError 的成因
坑一:直接运行包里的文件。
uv run --python 3.12 python impdemo/pkg/tools.py
输出:
[import] pkg/tools.py 被执行了
直接运行 tools.py 时才会打印这句
这次侥幸成功了(因为 tools.py 没有相对导入)。但换成有相对导入的 deep.py:
uv run --python 3.12 python impdemo/pkg/sub/deep.py
真实报错:
Traceback (most recent call last):
File "/private/tmp/verify/py/impdemo/pkg/sub/deep.py", line 1, in <module>
from ..tools import greet # 相对导入:.. 表示上一层包
^^^^^^^^^^^^^^^^^^^^^^^^^
ImportError: attempted relative import with no known parent package
原因:你直接运行一个文件时,Python 把它当成"顶层脚本",它的 __package__ 是空的,"上一层包"这个概念不存在,.. 无处可解析。
解法:用 -m 以模块方式运行。
cd impdemo
uv run --python 3.12 python -m pkg.tools
输出:
[import] pkg/__init__.py 被执行了
[import] pkg/tools.py 被执行了
直接运行 tools.py 时才会打印这句
注意和前面的差别:这次 pkg/__init__.py 也被执行了——因为 -m pkg.tools 是"把它当成包里的模块来运行",Python 知道它的父包是 pkg,相对导入就能工作了。
记住这条经验法则:
python 路径/文件.py用于运行入口脚本;python -m 包.模块用于运行包里的模块。二选一,别混。
坑二:ModuleNotFoundError: No module named 'xxx',但我明明装了。
99% 是装到了另一个环境。你用系统 pip 装的,却用 .venv 里的 python 跑(或者反过来)。诊断命令:
uv run python -c "import sys; print(sys.executable); print(sys.path)"
看第一行是不是你以为的那个 python。用 uv run 前缀就基本不会遇到这个问题。
坑三:循环导入(circular import)。
a.py 里 import b,b.py 里 import a,报错通常是 ImportError: cannot import name 'X' from partially initialized module。原因是 a 还没执行完就去执行 b,b 又要 a 里还没定义的东西。
三个解法,按推荐顺序:
- 重新设计:把公共部分抽到第三个模块
common.py。循环导入几乎总是"分层没分好"的症状——第 10 节的notekit是严格单向的:models←store←cli/web,models不认识任何人。 - 把 import 挪进函数里(延迟到调用时才导入)。第 10 节的
cli.py里import uvicorn就写在serve()函数内部——顺带好处是不跑serve命令时不用付它的加载时间。 - 只在类型标注里需要的话,用
if TYPE_CHECKING:包起来(运行时不导入)。
5.4 ✅ Checkpoint 5:import 机制
按上面结构建好 impdemo/,依次跑三条命令:
uv run --python 3.12 python impdemo/main.py # 应该 7 行全打印
uv run --python 3.12 python impdemo/pkg/sub/deep.py # 应该报 ImportError
cd impdemo && uv run --python 3.12 python -m pkg.tools # 应该成功,且多打印 __init__ 那行
第二条必须失败——这是本节最重要的实验。如果它成功了,说明你的 deep.py 里没写相对导入。
六、装饰器、生成器、闭包:读懂满屏的 @
FastAPI、pytest、Typer、LangChain 的代码里到处是 @。这一节把它拆开,你会发现没什么魔法。新建 d_advanced.py。
6.1 闭包:函数记住了它出生的环境
def make_counter():
n = 0
def inc():
nonlocal n # 不写 nonlocal 就会报 UnboundLocalError
n += 1
return n
return inc
c = make_counter()
print(c(), c(), c())
输出:
1 2 3
inc 是定义在 make_counter 内部的函数,它"记住"了外层的变量 n——即使 make_counter 已经返回了。这就是闭包,和 Kotlin 的 lambda 捕获变量是一回事。
nonlocal 是必须的。Python 的规则是"函数里只要对一个名字赋值,这个名字就默认是局部变量"。所以不写 nonlocal n,n += 1 会被理解成"给局部变量 n 加一",而局部的 n 还没有值 → UnboundLocalError。这个报错第一次遇到会很懵,记住原因就行。(对应的还有 global,用来改模块级变量——但 global 基本上都是设计缺陷的信号,别用。)
6.2 装饰器:@ 只是语法糖
import functools
import time
def timed(fn):
@functools.wraps(fn) # 不加这行,fn.__name__ 会变成 'wrapper'
def wrapper(*args, **kwargs):
t0 = time.perf_counter()
result = fn(*args, **kwargs)
ms = (time.perf_counter() - t0) * 1000
print(f" [timed] {fn.__name__} 用了 {ms:.1f} ms")
return result
return wrapper
@timed
def slow_add(a, b):
time.sleep(0.05)
return a + b
print(" 结果:", slow_add(1, 2))
print(" 函数名还在:", slow_add.__name__)
输出:
[timed] slow_add 用了 53.5 ms
结果: 3
函数名还在: slow_add
等价写法: slow_add = timed(slow_add)
装饰器就三句话:
- 它是一个接受函数、返回函数的函数。
@timed写在def slow_add上面,完全等价于slow_add = timed(slow_add)——没有任何魔法,就是个赋值。- 返回的
wrapper用*args, **kwargs接住所有参数再转发,这样它能装饰任何签名的函数(3.1 节的伏笔在这里回收)。
@functools.wraps(fn) 别省。不加它,slow_add.__name__ 会变成 'wrapper',文档字符串也丢了——调试时看到一堆叫 wrapper 的函数会很痛苦,而且有些框架(比如 FastAPI)依赖函数元数据工作。
对照 Kotlin:装饰器最接近的类比是注解 + 编译期代码生成(比如 Room 的 @Dao),但 Python 的是运行时发生的,而且你自己十行就能写一个。
6.3 带参数的装饰器:三层套娃
def retry(times=3):
def deco(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
for i in range(1, times + 1):
try:
return fn(*args, **kwargs)
except Exception as e:
print(f" 第{i}次失败: {e}")
if i == times:
raise
return None
return wrapper
return deco
calls = {"n": 0}
@retry(times=3)
def flaky():
calls["n"] += 1
if calls["n"] < 3:
raise RuntimeError("网络抖动")
return "成功了"
print(" 结果:", flaky())
输出:
第1次失败: 网络抖动
第2次失败: 网络抖动
结果: 成功了
为什么要三层?因为 @retry(times=3) 里 retry(times=3) 先被调用,它得返回一个"真正的装饰器",那个装饰器再接收函数。所以层次是:retry(参数) → deco(函数) → wrapper(调用时的参数)。
这个 retry 是生产代码里的常客——调 LLM API 会超时、会限流,不重试的调用等于没有容错。注意 if i == times: raise:最后一次失败要把异常抛出去,不能悄悄返回 None,否则调用方拿到 None 会在别的地方炸,排查成本翻倍。
6.4 标准库自带的装饰器:lru_cache
@functools.lru_cache(maxsize=None)
def fib(n):
return n if n < 2 else fib(n - 1) + fib(n - 2)
t0 = time.perf_counter()
print(" fib(90) =", fib(90))
print(f" 用了 {(time.perf_counter()-t0)*1000:.2f} ms(没有缓存的话要跑到宇宙尽头)")
print(" 缓存命中情况:", fib.cache_info())
输出:
fib(90) = 2880067194370816120
用了 0.02 ms(没有缓存的话要跑到宇宙尽头)
缓存命中情况: CacheInfo(hits=88, misses=91, maxsize=None, currsize=91)
一行装饰器把指数级算法变成线性的。 没有缓存的递归 fib(90) 要算约 2⁹⁰ 次;有了缓存,misses=91(每个 n 只真算一次)、hits=88(其余全部命中缓存),总共 0.02 毫秒。
顺便注意:fib(90) = 2880067194370816120 这个数已经超过了 Kotlin Int 的范围(在 Long 范围内),Python 完全不用操心(2.3 节)。
用它的三个前提:函数必须是纯函数(同样输入必然同样输出,没有副作用);参数必须可哈希(不能是 list 或 dict);maxsize 设成 None 意味着永不淘汰,有内存泄漏风险——线上代码请给个具体数字,比如 maxsize=1024。
6.5 生成器:yield 是暂停键
def read_lines(n):
print(" [生成器] 被调用时函数体一行都没执行")
for i in range(1, n + 1):
print(f" [生成器] 生产第 {i} 行")
yield f"line-{i}"
gen = read_lines(3)
print(" 拿到的是:", type(gen).__name__)
print(" 取第一个:", next(gen))
print(" 取第二个:", next(gen))
print(" 剩下的一次拿完:", list(gen))
输出:
拿到的是: generator
[生成器] 被调用时函数体一行都没执行
[生成器] 生产第 1 行
取第一个: line-1
[生成器] 生产第 2 行
取第二个: line-2
[生成器] 生产第 3 行
剩下的一次拿完: ['line-3']
请仔细看输出顺序,它揭示了生成器的全部行为:
- 调用
read_lines(3)时,函数体一行都没执行(第一个 print 出现在"拿到的是"之后!),只是返回了一个 generator 对象。 - 每次
next(gen),函数从上次yield的位置继续执行,遇到下一个yield就暂停并交出值。 - 局部变量(这里的
i)在暂停期间保持不变。
一句话:只要函数里有 yield,它就不再是普通函数,而是一个"可以暂停和恢复的过程"。 这和 Kotlin 的 sequence { yield(...) } 是同一个概念。
它的价值是内存:
import sys
print(" 列表 [x*x for x in range(100000)] 占:",
sys.getsizeof([x * x for x in range(100000)]), "字节")
print(" 生成器 (x*x for x in range(100000)) 占:",
sys.getsizeof(x * x for x in range(100000)), "字节")
输出:
列表 [x*x for x in range(100000)] 占: 800984 字节
生成器 (x*x for x in range(100000)) 占: 200 字节
80 万字节 vs 200 字节,差 4000 倍。 列表把 10 万个结果全算出来存下;生成器只存"怎么算下一个"。所以处理大文件、大数据集、或者流式接收 LLM 的输出时,一律用生成器。
注意语法差别只有括号:[...] 是列表推导式,(...) 是生成器表达式。
代价要知道:生成器只能遍历一次(遍历完就空了,上面 list(gen) 之后 gen 就废了),也不能取长度、不能按下标访问。需要反复用就老实生成列表。
6.6 async/await:等待时去干别的
import asyncio
def sync_fetch(name, delay):
time.sleep(delay)
return f"{name} 完成"
async def async_fetch(name, delay):
await asyncio.sleep(delay) # 注意是 asyncio.sleep 不是 time.sleep
return f"{name} 完成"
async def main():
t0 = time.perf_counter()
results = await asyncio.gather(
async_fetch("模型A", 0.3),
async_fetch("模型B", 0.3),
async_fetch("模型C", 0.3),
)
print(" 异步并发:", results)
print(f" 异步耗时 {(time.perf_counter()-t0):.2f} 秒")
t0 = time.perf_counter()
print(" 同步串行:", [sync_fetch(n, 0.3) for n in ("模型A", "模型B", "模型C")])
print(f" 同步耗时 {(time.perf_counter()-t0):.2f} 秒")
asyncio.run(main())
输出:
同步串行: ['模型A 完成', '模型B 完成', '模型C 完成']
同步耗时 0.91 秒
异步并发: ['模型A 完成', '模型B 完成', '模型C 完成']
异步耗时 0.30 秒
0.91 秒 → 0.30 秒。三个各等 0.3 秒的任务,同步是加法(0.3×3),异步是取最大值(3 个等待重叠在一起)。
四个概念:
| 写法 | 含义 |
|---|---|
async def f(): |
声明协程函数,调用它不会执行,只返回一个协程对象 |
await x |
"我要等 x,等的期间事件循环去跑别的" |
asyncio.gather(a, b, c) |
同时启动多个,全部完成后一起返回 |
asyncio.run(main()) |
从普通同步代码进入异步世界的唯一入口 |
对照 Android:async def ≈ suspend fun,await ≈ 直接调用 suspend 函数,asyncio.gather ≈ coroutineScope { async{}; async{} },asyncio.run ≈ runBlocking。概念是一对一的,你已经会了。
6.7 最常见的 async 错误:混进阻塞调用
async def wrong_one(delay):
time.sleep(delay) # 错!应该是 await asyncio.sleep(delay)
async def wrong():
t0 = time.perf_counter()
await asyncio.gather(*[wrong_one(0.2) for _ in range(3)])
print(f" 用错 time.sleep 的耗时 {(time.perf_counter()-t0):.2f} 秒 —— 完全没并发")
asyncio.run(wrong())
输出:
用错 time.sleep 的耗时 0.61 秒 —— 完全没并发
0.61 ≈ 0.2 × 3,一点都没快。因为 time.sleep 是阻塞的:它把整个线程(也就是整个事件循环)卡住,其它协程根本没机会跑。
这是 async 代码里最高频的 bug,而且它不报错,只是悄悄地不生效。 记住这条对照表:
| 阻塞版(不能在 async 里用) | 异步版 |
|---|---|
time.sleep(1) |
await asyncio.sleep(1) |
requests.get(...) |
await httpx.AsyncClient().get(...) |
open(...).read() |
await aiofiles.open(...) 或丢给线程池 |
| 任何 CPU 密集的循环 | await asyncio.to_thread(...) / 进程池 |
判断方法:async def 函数里出现的每一个"会等待"的调用,前面都必须有 await。看到裸的 time.sleep 或 requests. 就是错的。实在没有异步版本(比如某个只有同步 SDK 的库),用 await asyncio.to_thread(阻塞函数, 参数) 把它扔到线程里去。
6.8 ✅ Checkpoint 6:装饰器 / 生成器 / 异步
uv run --python 3.12 python d_advanced.py
完整真实输出:
--- 1. 闭包 ---
1 2 3
--- 2. 装饰器 ---
[timed] slow_add 用了 53.5 ms
结果: 3
函数名还在: slow_add
等价写法: slow_add = timed(slow_add)
--- 3. 带参数的装饰器 ---
第1次失败: 网络抖动
第2次失败: 网络抖动
结果: 成功了
--- 4. lru_cache ---
fib(90) = 2880067194370816120
用了 0.02 ms(没有缓存的话要跑到宇宙尽头)
缓存命中情况: CacheInfo(hits=88, misses=91, maxsize=None, currsize=91)
--- 5. 生成器 ---
拿到的是: generator
[生成器] 被调用时函数体一行都没执行
[生成器] 生产第 1 行
取第一个: line-1
[生成器] 生产第 2 行
取第二个: line-2
[生成器] 生产第 3 行
剩下的一次拿完: ['line-3']
--- 6. 生成器省内存 ---
列表 [x*x for x in range(100000)] 占: 800984 字节
生成器 (x*x for x in range(100000)) 占: 200 字节
--- 7. 同步 vs 异步 ---
同步串行: ['模型A 完成', '模型B 完成', '模型C 完成']
同步耗时 0.91 秒
异步并发: ['模型A 完成', '模型B 完成', '模型C 完成']
异步耗时 0.30 秒
用错 time.sleep 的耗时 0.61 秒 —— 完全没并发
记住: async 函数里所有阻塞调用都必须换成 await 版本,否则白写。
自查三处:[生成器] 被调用时... 出现在 拿到的是: generator 之后(惰性求值);0.91 秒 vs 0.30 秒(异步生效);0.61 秒(用错 sleep 就白写)。耗时数字会有几十毫秒波动,量级对上就行。
七、类型提示与 mypy:把编译器请回来
3.3 节我们证明了"类型提示运行时不生效"。这一节把它变得有用。新建 j_typing.py。
7.1 基础写法速查
name: str = "小林"
count: int = 0
ratio: float = 0.5
ok: bool = True
tags: list[str] = [] # 3.9+ 可以直接用内置类型
scores: dict[str, int] = {}
pair: tuple[int, str] = (1, "a")
maybe: str | None = None # 3.10+,等价于 Optional[str]
either: int | str = 1 # 3.10+,等价于 Union[int, str]
def f(items: list[str], sep: str = ",") -> str:
return sep.join(items)
def g() -> None: # 没有返回值就标 None
print("hi")
三个演进要记住(这解释了为什么你在网上会看到两套写法):
| 老写法(3.8 及以前) | 新写法 | 从哪个版本起 |
|---|---|---|
from typing import List → List[str] |
list[str] |
3.9 |
Optional[str] |
str \| None |
3.10 |
Union[int, str] |
int \| str |
3.10 |
TypeVar("T") + Generic[T] |
def f[T](x: T) -> T |
3.12 |
新项目一律用新写法,ruff 的 UP 规则组会自动帮你改。规则号是实测出来的——对 Optional[str] 报 UP045 Use X | None for type annotations,对 Union[int, str] 报 UP007 Use X | Y for type annotations,都带 [*] 标记(表示 ruff check --fix 能自动修)。第 10.12 节会把 UP 加进 notekit 的规则集。
7.2 TypedDict:给字典标上"有哪些键"
from typing import TypedDict
class ToolCall(TypedDict):
name: str
args: dict[str, str]
def run_tool(call: ToolCall) -> str:
return f"调用 {call['name']},参数 {call['args']}"
print(" ", run_tool({"name": "get_weather", "args": {"city": "北京"}}))
输出:
调用 get_weather,参数 {'city': '北京'}
Python 里到处是字典(JSON 解析结果、LLM 返回的工具调用),dict[str, Any] 这种标注等于没标。TypedDict 让 mypy 知道有哪些键、每个键什么类型,这样写错键名会被抓出来。
它只是静态标注,运行时就是普通 dict——没有任何开销,也没有任何校验。要运行时校验就用 pydantic(第 9.2 节)。
7.3 Protocol:结构化的接口
from collections.abc import Iterable
from typing import Protocol
class Speaker(Protocol):
"""任何有 speak() -> str 的对象都算 Speaker,不需要显式继承。
相当于 Kotlin 的 interface,但是『结构性』的:长得像就算是。
"""
def speak(self) -> str: ...
class Dog:
def speak(self) -> str:
return "汪"
class Robot:
def speak(self) -> str:
return "beep"
def make_noise(things: Iterable[Speaker]) -> list[str]:
return [t.speak() for t in things]
print(" ", make_noise([Dog(), Robot()]))
输出:
['汪', 'beep']
这是 3.7 节鸭子类型的"正式化"。Dog 和 Robot 都没有继承 Speaker,但 mypy 认为它们符合——因为它们的方法签名对得上。这叫结构化子类型(structural subtyping)。
和 Kotlin 的 interface 的差别:Kotlin 是名义的(必须写 : Speaker 才算),Python 的 Protocol 是结构的(长得像就算)。好处是你可以给第三方库的类追加协议——你没法改人家的代码去实现你的接口,但可以定义一个 Protocol 说"我需要的是有 read() 方法的东西"。
... 是 Ellipsis 字面量,在这里当"函数体省略"用(写 pass 也行,但 ... 是 Protocol 和存根文件的惯例)。
Iterable 从 collections.abc 导入而不是 typing——后者从 3.9 起废弃了。参数类型尽量标最宽松的抽象类型(Iterable 而不是 list),返回值标最具体的(list[str] 而不是 Iterable[str]),这样调用方最自由。
7.4 泛型:3.12 起简单多了
from collections.abc import Callable
def first_or(items: list[str], default: str) -> str:
return items[0] if items else default
def apply_twice[T](fn: Callable[[T], T], value: T) -> T:
"""3.12 起可以直接写 [T],不用先 TypeVar("T")。"""
return fn(fn(value))
print(" ", first_or([], "空的"))
print(" ", apply_twice(lambda s: s + "!", "hi"))
print(" ", apply_twice(lambda n: n * 2, 3))
输出:
空的
hi!!
12
def apply_twice[T](...) 就是 Kotlin 的 fun <T> applyTwice(...),写法几乎一样(3.12 之前要先 T = TypeVar("T"),啰嗦得多)。
Callable[[T], T] 读作"接受一个 T、返回一个 T 的函数"——相当于 Kotlin 的 (T) -> T。第一个 [] 里是参数列表。
lambda 参数: 表达式 是匿名函数,只能写一个表达式(不能有语句、不能多行)。需要多行就老老实实 def 一个有名字的函数——这是 Python 有意的设计选择。
7.5 mypy:把错误抓回来
def add(a: int, b: int) -> int:
return a + b
print(" add('1', '2') =", add("1", "2"), " <- 运行得很欢,但 mypy 会报错")
Python 跑起来是这样:
add('1', '2') = 12 <- 运行得很欢,但 mypy 会报错
"1" + "2" 是字符串拼接,结果 "12"。Python 一声不吭。 现在装上 mypy 再看:
uv run --python 3.12 --with mypy mypy j_typing.py
真实输出:
j_typing.py:70: error: Argument 1 to "add" has incompatible type "str"; expected "int" [arg-type]
j_typing.py:70: error: Argument 2 to "add" has incompatible type "str"; expected "int" [arg-type]
Found 2 errors in 1 file (checked 1 source file)
这就是你在 Kotlin 里免费得到的东西,在 Python 里要花一条命令换。
结论(请当成纪律):类型提示本身不做任何事,mypy(或 pyright)才是执行者。任何认真的 Python 项目都必须把 mypy 接进 CI,否则类型标注只是好看的注释。第 10.12 节会把它配进 pyproject.toml。
--with mypy 是 uv 的临时依赖语法:装一次、用完扔,不写进项目依赖。适合这种一次性检查。
7.6 ✅ Checkpoint 7:类型提示 + mypy 抓错
uv run --python 3.12 python j_typing.py
uv run --python 3.12 --with mypy mypy j_typing.py
第一条的完整输出:
--- 1. TypedDict ---
调用 get_weather,参数 {'city': '北京'}
--- 2. Protocol ---
['汪', 'beep']
--- 3. 泛型 ---
空的
hi!!
12
--- 4. 运行时不检查 ---
add('1', '2') = 12 <- 运行得很欢,但 mypy 会报错
所以类型提示要配合 mypy/pyright 才有价值,
它不是 Kotlin 那种编译期强制,而是『可选的静态检查』。
第二条必须报 2 个错。一个成功、一个报错,两个都对,这才算这节学通了——同一份代码,Python 说没问题,mypy 说有两处类型错误,而 mypy 是对的。
八、标准库与并发:GIL 到底挡了什么
8.1 标准库速查:这些不用装
Python 号称"自带电池",下面这些 import 就能用。新建 f_stdlib.py。
内置函数(连 import 都不用):
names = ["小林", "小王", "小李"]
scores = [91, 78, 85]
for i, (n, s) in enumerate(zip(names, scores), start=1):
print(f" 第{i}名候选: {n} {s}")
print(" 按分数倒序:", sorted(zip(names, scores), key=lambda p: -p[1]))
print(" any/all:", any(s > 90 for s in scores), all(s > 60 for s in scores))
print(" max 带 key:", max(zip(names, scores), key=lambda p: p[1]))
print(" sum:", sum(scores), "| min:", min(scores))
输出:
第1名候选: 小林 91
第2名候选: 小王 78
第3名候选: 小李 85
按分数倒序: [('小林', 91), ('小李', 85), ('小王', 78)]
any/all: True True
max 带 key: ('小林', 91)
sum: 254 | min: 78
| 函数 | 干什么 | Kotlin 对应 |
|---|---|---|
enumerate(xs, start=1) |
同时拿下标和元素 | withIndex() |
zip(a, b) |
把两个序列配对(按最短的截断) | zip |
sorted(xs, key=..., reverse=True) |
返回新列表(xs.sort() 是原地改) |
sortedBy |
any / all |
有没有一个 / 是不是全部 | any / all |
max / min / sum / len |
顾名思义,都支持 key= |
同名 |
key=lambda p: p[1] 是"按什么排",加个负号 -p[1] 或写 reverse=True 就是倒序。注意 enumerate 的第二个参数叫 start,默认从 0 开始——要从 1 开始给人看,必须显式写 start=1。
collections:四个高频容器
import collections
counter = collections.Counter("mississippi")
print(" Counter 最常见的3个:", counter.most_common(3))
dd = collections.defaultdict(list)
for word in ["apple", "avocado", "banana", "blueberry"]:
dd[word[0]].append(word)
print(" defaultdict 分组:", dict(dd))
dq = collections.deque(maxlen=3)
for i in range(6):
dq.append(i)
print(" deque(maxlen=3) 只留最后3个:", list(dq), " <- 做对话历史窗口很好用")
Point = collections.namedtuple("Point", "x y")
p = Point(1, 2)
print(" namedtuple:", p, p.x, p.y)
输出:
Counter 最常见的3个: [('i', 4), ('s', 4), ('p', 2)]
defaultdict 分组: {'a': ['apple', 'avocado'], 'b': ['banana', 'blueberry']}
deque(maxlen=3) 只留最后3个: [3, 4, 5] <- 做对话历史窗口很好用
namedtuple: Point(x=1, y=2) 1 2
Counter:计数神器,most_common(n)直接给出 Top-N。做词频、统计工具调用次数都用它。defaultdict(list):访问不存在的键时自动创建默认值,省掉if k not in d: d[k] = []这种样板代码。deque(maxlen=N):双端队列,满了自动挤掉最老的。这正是"只保留最近 N 轮对话"的标准实现——比手写if len(history) > N: history.pop(0)又快又不会错。namedtuple:轻量只读结构。现在一般优先用@dataclass,但读老代码会遇到。
dict 的常用操作
d = {"a": 1, "b": 2}
print(" get 带默认值:", d.get("c", 0))
print(" setdefault:", d.setdefault("c", 3), d)
print(" 合并(3.9+):", d | {"d": 4})
print(" items 遍历:", [f"{k}={v}" for k, v in d.items()])
print(" dict 保持插入顺序(3.7+):", list({"z": 1, "a": 2, "m": 3}))
print(" pop 带默认值:", d.pop("nope", "没有这个键"))
输出:
get 带默认值: 0
setdefault: 3 {'a': 1, 'b': 2, 'c': 3}
合并(3.9+): {'a': 1, 'b': 2, 'c': 3, 'd': 4}
items 遍历: ['a=1', 'b=2', 'c=3']
dict 保持插入顺序(3.7+): ['z', 'a', 'm']
pop 带默认值: 没有这个键
d["missing"] 会抛 KeyError,d.get("missing") 返回 None。 处理 LLM 返回的 JSON 时永远用 .get() 加默认值——模型少给一个字段是常态。
dict 从 3.7 起保证保持插入顺序(输出第五行 ['z', 'a', 'm'] 就是证据,不是排序后的 ['a','m','z'])。这个特性让"去重且保持顺序"有了一个惯用写法,第 10.3 节的标签去重就是这么做的。
字符串处理
import re
line = " Name: 小林 , Age: 28 "
print(" strip:", repr(line.strip()))
print(" split:", [s.strip() for s in line.split(",")])
print(" startswith/in:", line.strip().startswith("Name"), "Age" in line)
print(" replace:", "a-b-c".replace("-", "/"))
print(" partition:", "key: value".partition(":"))
print(" join:", ", ".join(names))
print(" ljust 对齐:", "|" + "名字".ljust(6) + "|")
print(" 正则:", re.findall(r"\d+", line), re.sub(r"\s+", " ", line).strip())
m = re.search(r"Name:\s*(\S+)", line)
print(" 分组:", m.group(1) if m else None)
输出:
strip: 'Name: 小林 , Age: 28'
split: ['Name: 小林', 'Age: 28']
startswith/in: True True
replace: a/b/c
partition: ('key', ':', ' value')
join: 小林, 小王, 小李
ljust 对齐: |名字 |
正则: ['28'] Name: 小林 , Age: 28
分组: 小林
strip() 去两端空白、split(",") 切分、join 拼接、partition(":") 按第一个分隔符切成三段(前、分隔符本身、后)——partition 比 split 安全,因为它永远返回 3 个元素,不用担心索引越界。
正则用 r"..."(raw 字符串,反斜杠不转义):re.findall 找全部、re.search 找第一个(返回 None 或匹配对象,必须判空)、re.sub 替换、m.group(1) 取第一个捕获组。
match 语句:3.10+ 的加强版 when
def describe(x):
match x:
case 0:
return "零"
case int() | float() if x < 0:
return "负数"
case int():
return f"正整数 {x}"
case [a, b]:
return f"两个元素的列表: {a},{b}"
case {"type": t, **rest}:
return f"字典,type={t},其余={rest}"
case str() as s:
return f"字符串 长度{len(s)}"
case _:
return "别的什么"
for v in [0, -3, 7, [1, 2], {"type": "tool", "name": "search"}, "hello", 3.14j]:
print(f" {v!r:>34} -> {describe(v)}")
输出:
0 -> 零
-3 -> 负数
7 -> 正整数 7
[1, 2] -> 两个元素的列表: 1,2
{'type': 'tool', 'name': 'search'} -> 字典,type=tool,其余={'name': 'search'}
'hello' -> 字符串 长度5
3.14j -> 别的什么
match 比 Kotlin 的 when 强的地方是结构解构:case [a, b] 直接匹配"两个元素的列表"并把元素绑到 a/b;case {"type": t, **rest} 匹配"含 type 键的字典",把值绑到 t、剩下的收进 rest。解析 LLM 返回的工具调用 JSON 时非常顺手。
三个细节:case int() 是类型匹配(带括号,别写成 case int——那是匹配"等于 int 这个类对象");if x < 0 是守卫条件;case _ 是兜底(相当于 else)。从上往下第一个匹配的生效,所以 case 0 必须写在 case int() 前面。
时间:最容易出 bug 的地方
from datetime import datetime, timedelta, timezone
now = datetime.now(timezone.utc)
print(" 带时区的现在:", now.isoformat()[:19] + "...")
print(" 格式化:", now.strftime("%Y-%m-%d %H:%M"))
print(" 加一天:", (now + timedelta(days=1)).strftime("%Y-%m-%d"))
print(" 解析字符串:", datetime.strptime("2026-08-16", "%Y-%m-%d").date())
naive = datetime.now()
try:
_ = naive < now
except TypeError as e:
print(" 报错:", e)
输出:
带时区的现在: 2026-08-16T09:49:43...
格式化: 2026-08-16 09:49
加一天: 2026-08-17
解析字符串: 2026-08-16
不带时区的(naive)和带时区的不能直接比 —— 这是最常见的时间 bug
报错: can't compare offset-naive and offset-aware datetimes
这个报错请记住:Python 的 datetime 分两种——naive(不带时区,datetime.now())和 aware(带时区,datetime.now(timezone.utc))。两者不能比较、不能相减,直接抛 TypeError。
纪律:程序内部一律用带时区的 UTC 时间(datetime.now(UTC)),只在显示给用户的最后一步转本地时区。第 10.3 节的 Note.created_at 就是这么做的。
(UTC 这个简写从 3.11 起可以直接 from datetime import UTC,比 timezone.utc 短。ruff 的 UP017 规则会自动帮你改。)
itertools:迭代工具箱
import itertools
print(" chain:", list(itertools.chain([1, 2], [3, 4])))
print(" islice(取前3个,可用于无限序列):", list(itertools.islice(itertools.count(10), 3)))
print(" 批量分组(3.12+ batched):", [list(b) for b in itertools.batched(range(7), 3)])
输出:
chain: [1, 2, 3, 4]
islice(取前3个,可用于无限序列): [10, 11, 12]
批量分组(3.12+ batched): [[0, 1, 2], [3, 4, 5], [6]]
chain 把多个序列串成一个、islice 对无限序列也能安全取前 N 个、batched(xs, n) 按 n 个一组切分(3.12 才有,之前得自己写)。批量调用 API、批量做 embedding 时 batched 特别有用——注意最后一组可以不满(上面是 [6])。
8.2 ✅ Checkpoint 8:标准库
uv run --python 3.12 python f_stdlib.py
7 个小节全部打印,最后一行应该是 批量分组(3.12+ batched): [[0, 1, 2], [3, 4, 5], [6]]。如果这行报 AttributeError: module 'itertools' has no attribute 'batched',说明你的 Python 不是 3.12+,回到第 1 节。
8.3 是什么:GIL
GIL(Global Interpreter Lock,全局解释器锁) 是 CPython(就是官方那个 Python)里的一把大锁:同一时刻只允许一个线程执行 Python 字节码。
这句话的后果非常反直觉:在 Python 里开 4 个线程做纯计算,不会比 1 个线程快,甚至可能更慢(因为多了切换开销)。这和 Android 上开线程池加速计算的经验完全相反。
为什么会有它?简化了 CPython 内部内存管理(引用计数不用逐对象加锁),代价是牺牲了多核并行。注意 GIL 是 CPython 的实现细节,不是 Python 语言规范;3.13 起有一个实验性的"free-threaded"构建可以关掉它,但目前生产环境还不是默认。
8.4 怎么用:三种并发,各管一段
别背结论,看实测。 新建 g_gil.py——注意所有多进程代码必须写在 if __name__ == "__main__": 里面(原因在 8.5 讲,那是我踩过的坑):
"""G: GIL 实测 —— 线程 / 进程 / 异步该用哪个。
注意:多进程代码必须写在 if __name__ == "__main__": 里面,
否则子进程会重新 import 这个文件,把上面的代码再跑一遍(甚至无限递归)。
"""
import multiprocessing
import os
import threading
import time
def cpu_heavy(n=6_000_000):
"""纯计算:一直在算,不等任何东西。"""
total = 0
for i in range(n):
total += i * i
return total
def io_heavy(delay=0.3):
"""纯等待:什么都不算,就是等。"""
time.sleep(delay)
return delay
def timed(label, fn):
t0 = time.perf_counter()
fn()
print(f" {label:<24} {time.perf_counter() - t0:.2f} 秒")
def run_threads(fn, k=4):
ts = [threading.Thread(target=fn) for _ in range(k)]
for t in ts:
t.start()
for t in ts:
t.join()
if __name__ == "__main__":
print(f"CPU 核心数: {os.cpu_count()}")
print("--- CPU 密集型:多线程完全没用 ---")
timed("单线程 跑 4 遍", lambda: [cpu_heavy() for _ in range(4)])
timed("4 个线程", lambda: run_threads(cpu_heavy, 4))
with multiprocessing.Pool(4) as pool:
timed("4 个进程", lambda: pool.map(cpu_heavy, [6_000_000] * 4))
print("--- IO 密集型:多线程立刻有效 ---")
timed("单线程 跑 4 遍", lambda: [io_heavy() for _ in range(4)])
timed("4 个线程", lambda: run_threads(io_heavy, 4))
跑:
uv run --python 3.12 python g_gil.py
真实输出(8 核 M 系列 Mac):
CPU 核心数: 8
--- CPU 密集型:多线程完全没用 ---
单线程 跑 4 遍 0.70 秒
4 个线程 0.67 秒
4 个进程 0.22 秒
--- IO 密集型:多线程立刻有效 ---
单线程 跑 4 遍 1.22 秒
4 个线程 0.30 秒
这四行数字是本节的全部结论:
| 场景 | 单线程 | 4 线程 | 4 进程 | 结论 |
|---|---|---|---|---|
| CPU 密集(纯计算) | 0.70s | 0.67s | 0.22s | 线程几乎没提升;进程 3 倍多 |
| IO 密集(等待) | 1.22s | 0.30s | —— | 线程4 倍,立刻有效 |
- CPU 密集用多线程 = 白干(0.70 → 0.67,在测量噪声范围内)。GIL 让 4 个线程排队执行。
- CPU 密集用多进程 = 真的快(0.70 → 0.22,约 3.2 倍)。每个进程有自己的解释器和自己的 GIL,真正跑在不同核上。代价是进程启动慢、内存翻倍、参数和返回值要能被 pickle 序列化(lambda、局部函数、打开的文件对象都不行)。
- IO 密集用多线程 = 真的快(1.22 → 0.30,约 4 倍)。因为线程在
time.sleep/ 等网络时会释放 GIL,让别的线程跑。
第三种选择是 asyncio(6.6 节),它也解决 IO 等待,但不开线程——单线程内靠事件循环切换。相比多线程:省内存(协程比线程轻几个数量级)、没有锁的问题、能开成千上万个;代价是整条调用链都得是异步的(一个阻塞调用就毁掉全部,6.7 节的实测)。
决策表(背这个就够了):
| 你的任务 | 用什么 | 本章哪里有例子 |
|---|---|---|
| 等网络 / 等 API / 等数据库(Agent 的主要场景) | asyncio,其次线程池 |
6.6、9.3、11.4 |
| 纯计算(矩阵、编解码、大量字符串处理) | 多进程(multiprocessing / ProcessPoolExecutor) |
8.4 |
| 只有同步 SDK,但要在异步代码里用 | await asyncio.to_thread(...) |
6.7 表格 |
| Web 服务要吃满多核 | 多进程(uvicorn --workers 4) |
12.2 |
一个反直觉但重要的推论:写 Agent 时你几乎永远不需要
multiprocessing。Agent 的时间 99% 花在等大模型返回上,那是 IO 等待,asyncio就够了。GIL 对你的实际影响远小于传闻。
8.5 踩坑实录:多进程会把你的文件重跑一遍
我最初把 GIL 实验和标准库演示写在同一个文件里,结果输出是这样的:第 1~7 节的内容打印了三遍。
原因:multiprocessing 在 macOS 和 Windows 上默认用 spawn 方式创建子进程——子进程会重新 import 主模块来获得函数定义。于是模块顶层的所有代码(包括那 7 节演示)在每个子进程里又跑了一遍。
解法就是 5.2 节的规则三:把要执行的代码放进 if __name__ == "__main__":。子进程 import 主模块时 __name__ 是 "__mp_main__" 而不是 "__main__",所以那一块不会被执行。
这不是风格问题,是正确性问题。 我最后把 GIL 实验单独拆成 g_gil.py,整个执行部分都在 if __name__ == "__main__": 里面(上面的代码就是拆完的版本)。任何用到 multiprocessing 的文件都必须这么写,否则你会看到重复输出,最坏情况是无限递归创建进程。
九、主要库:一张表 + 三个最小例子
到这里语法部分结束。这一节先概览生态,第 10 节把它们全用起来。
9.1 六个库,六件事
| 库 | 干什么 | Android 类比 | 为什么是它 |
|---|---|---|---|
| pydantic | 数据校验与序列化 | Moshi/Gson + 校验 | 事实标准,FastAPI 和 LangChain 都建在它上面 |
| typer | 命令行参数解析 | —— | 靠类型提示自动生成 CLI 和 --help |
| rich | 终端彩色输出、表格、进度条 | —— | 让 CLI 好看,typer 自带 |
| httpx | HTTP 客户端 | Retrofit/OkHttp | 比 requests 多了异步支持 |
| FastAPI | Web 框架 | Spring Boot | 靠类型提示自动校验 + 自动生成 API 文档 |
| pytest | 测试框架 | JUnit | 断言直接写 assert |
| ruff | 格式化 + 静态检查 | ktlint + detekt | Rust 写的,快到几乎无感 |
| mypy | 类型检查 | Kotlin 编译器(的类型部分) | 见第 7 节 |
还有几个你会经常听到的:python-dotenv(读 .env)、uvicorn(跑 FastAPI 的服务器,相当于 Tomcat)、numpy/pandas(数值和表格计算)、openai(各家大模型的兼容 SDK)。
9.2 pydantic:带校验的 data class
from pydantic import BaseModel, Field, ValidationError
class NoteCreate(BaseModel):
title: str = Field(min_length=1, max_length=100)
body: str = ""
tags: list[str] = Field(default_factory=list)
ok = NoteCreate(title="学 Python", tags=["py"])
print(ok)
print(ok.model_dump())
try:
NoteCreate(title="")
except ValidationError as e:
print("校验失败:", e.errors()[0]["type"], e.errors()[0]["loc"])
pydantic 和 @dataclass 的关键差别:它会在运行时真的校验和转换类型。 @dataclass 只是生成样板代码,你传什么它都收。
三个能力:Field(min_length=..., max_length=..., ge=..., le=...) 声明约束;类型会在"安全"的前提下自动转换(把 "42" 传给 int 字段会得到 42,但把 123 传给 str 字段会被拒绝——具体规则见下面的 Checkpoint 9);model_dump() 转成 dict、model_validate(d) 从 dict 还原、model_dump_json() 直接出 JSON 字符串。
pydantic 有 v1 和 v2 两代,API 名字全变了(.dict() → .model_dump()、.parse_obj() → .model_validate()、@validator → @field_validator)。网上搜到的教程一半是 v1 的,看到 .dict() 就知道那是旧的。本章全部用 v2。
9.3 httpx:同步和异步两副面孔
import httpx
# 同步
with httpx.Client(base_url="http://127.0.0.1:8123", timeout=5.0) as client:
r = client.get("/health")
print(r.status_code, r.json())
# 异步
async def main():
async with httpx.AsyncClient(base_url="http://127.0.0.1:8123") as client:
r = await client.get("/health")
return r.json()
四个必须知道的点:
- 用
with httpx.Client(...)而不是每次httpx.get(...)。Client 会复用 TCP 连接(连接池),高频调用时差别很大——相当于复用 OkHttpClient 而不是每次新建。 timeout必须显式传。不传的默认值是 5 秒,但更重要的是养成习惯:任何网络调用都要有超时,否则一个卡住的请求能挂死整个服务。- 4xx/5xx 不会自动抛异常,
r.status_code就是 404 而已。想抛得自己调r.raise_for_status()。这和 Retrofit 的行为不同,是新手最容易漏的地方。 httpxvsrequests:API 几乎一样,但 httpx 支持async。新项目直接用 httpx。
9.4 FastAPI:类型提示当校验规则
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, Query
api = FastAPI(title="notekit API", version="0.1.0")
@api.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
FastAPI 的核心卖点:你写的类型提示就是校验规则和 API 文档。声明 note_id: int,传 abc 会自动返回 422 和详细错误;声明请求体是某个 pydantic 模型,字段校验自动生效;/docs 自动生成可交互的 API 文档,不用写一行 Swagger 注解。
第 10.9 节会完整实现,第 10.10 节会看到 422 的真实响应体。
9.5 ✅ Checkpoint 9:pydantic 的校验与转换规则
uv run --python 3.12 --with pydantic python -c "
from pydantic import BaseModel, Field, ValidationError
class NoteCreate(BaseModel):
title: str = Field(min_length=1, max_length=100)
tags: list[str] = Field(default_factory=list)
class Score(BaseModel):
n: int
print('正常:', NoteCreate(title='学 Python', tags=['py']))
print('字符串数字会转成 int:', Score(n='42'))
try:
NoteCreate(title=123)
except ValidationError as e:
print('int 传给 str 字段被拒绝:', e.errors()[0]['type'])
try:
NoteCreate(title='')
except ValidationError as e:
print('空标题被拒绝:', e.errors()[0]['type'])
try:
Score(n='abc')
except ValidationError as e:
print('转不了的字符串被拒绝:', e.errors()[0]['type'])
"
真实输出:
正常: title='学 Python' tags=['py']
字符串数字会转成 int: n=42
int 传给 str 字段被拒绝: string_type
空标题被拒绝: string_too_short
转不了的字符串被拒绝: int_parsing
这四种结果值得对照记住:pydantic 的默认(宽松)模式只做"无损且无歧义"的转换——"42" → 42 可以(JSON 里数字常被当字符串传),但 123 → "123" 不行(那会掩盖类型错误),"abc" → int 更不行。约束(min_length)在转换之后才检查。
每个错误都有一个稳定的 type 字符串(string_type / string_too_short / int_parsing),这个字段可以直接用来做前端错误提示的分支判断,比解析错误文本可靠得多。第 10.10 节会在 HTTP 422 的响应体里再看到它。
十、动手实战:从空目录敲出 notekit
这一节是本章的主线。 我们要做一个笔记工具 notekit,它有两副面孔——命令行和 HTTP API——但共用同一套业务逻辑。做完你会得到一个"结构正确的 Python 项目"的样板,以后所有项目都可以照着搭。
分层设计(严格单向依赖,这是避免循环导入的根本办法):
models.py ← 数据长什么样(pydantic) 谁都不依赖
↑
store.py ← 数据存哪里、怎么增删改查 只依赖 models
↑
config.py ← 配置从哪读(环境变量/.env) 谁都不依赖
↑
cli.py ← 命令行界面(typer + rich) 依赖 store/models/config
web.py ← HTTP 接口(FastAPI) 依赖 store/models/config
对照 Android:models 是数据类、store 是 Repository、cli/web 是两个不同的"UI 层"。注意 store 完全不知道谁在用它,所以 CLI 和 Web 能共享它,测试也能直接测它。
10.1 uv init:生成项目骨架
uv init --package notekit --python 3.12
cd notekit
真实输出:
Initialized project `notekit` at `/private/tmp/verify/py/notekit`
生成的文件(我用一个同样的 initdemo 演示,结构完全一致):
initdemo/
├── .git/ # 顺手初始化了 git 仓库
├── .gitignore
├── .python-version # 内容就是 3.12
├── README.md
├── pyproject.toml
└── src/
└── initdemo/
└── __init__.py
pyproject.toml 的初始内容:
[project]
name = "initdemo"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = []
[project.scripts]
initdemo = "initdemo:main"
[build-system]
requires = ["uv_build>=0.11.32,<0.12.0"]
build-backend = "uv_build"
逐块解释这个文件(它就是你的 build.gradle.kts):
| 块 | 作用 | Gradle 对应 |
|---|---|---|
[project] name/version |
包名和版本 | group / version |
requires-python |
最低 Python 版本 | compileSdk / jvmTarget |
dependencies |
运行时依赖 | implementation |
[project.scripts] |
装完之后有哪些命令行命令 | 无直接对应(有点像 manifest 声明入口) |
[build-system] |
用什么工具打包 | Gradle 插件 |
[project.scripts] 里的 initdemo = "initdemo:main" 读法是"命令名 = 模块:函数"。装好之后敲 initdemo 就等于调用 initdemo 包里的 main() 函数。生成的 __init__.py 里正好有一个:
def main() -> None:
print("Hello from initdemo!")
试一下(第一次 uv run 会顺手建好虚拟环境并把项目装进去):
uv run initdemo
真实输出(我用 initcheck 这个名字跑的,你的名字换成你的):
Using CPython 3.12.13 interpreter at: /opt/homebrew/opt/python@3.12/bin/python3.12
Creating virtual environment at: .venv
Building initcheck @ file:///private/tmp/verify/py/initcheck
Built initcheck @ file:///private/tmp/verify/py/initcheck
Installed 1 package in 3ms
Hello from initcheck!
注意前四行:uv run 发现还没有 .venv,就自动建了一个、把你的项目以"可编辑模式"装进去、然后才执行命令。你没有敲任何"创建环境"或"安装"的命令——这就是 1 节说的 uv run 会自己保证环境一致。对照 Gradle:相当于 ./gradlew run 会自动 sync 依赖。
为什么是 src/ 布局(src-layout)? 源码放在 src/包名/ 下而不是直接放项目根目录,好处是测试时被迫用"已安装的包"而不是"当前目录的文件"——这样能提前发现"我忘了把某个文件加进包里"这类打包错误。这是现在 Python 社区的推荐做法。
.gitignore 也自动生成好了:
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv
10.2 装依赖
uv add "pydantic>=2" "typer" "fastapi" "uvicorn[standard]" "httpx" "python-dotenv"
uv add --dev pytest ruff mypy
uv add 会做三件事:写进 pyproject.toml → 更新 uv.lock → 装进 .venv。相当于改 build.gradle + sync 一步完成。真实输出长这样(以 uv add "rich>=14" 为例):
Resolved 5 packages in 1.08s
Building initcheck @ file:///private/tmp/verify/py/initcheck
Built initcheck @ file:///private/tmp/verify/py/initcheck
Prepared 1 package in 9ms
Uninstalled 1 package in 1ms
Installed 5 packages in 14ms
~ initcheck==0.1.0 (from file:///private/tmp/verify/py/initcheck)
+ markdown-it-py==4.2.0
+ mdurl==0.1.2
+ pygments==2.20.0
+ rich==15.0.0
我只要了 rich 一个包,装进来 4 个——markdown-it-py/mdurl/pygments 是 rich 自己的依赖。~ initcheck 那行是你自己的项目被重新装了一遍(因为 pyproject.toml 变了)。
--dev 的依赖只在开发时用(测试、检查工具),打包发布时不会带上——就是 testImplementation。它们会被写进 [dependency-groups] 的 dev 组。
uvicorn[standard] 里的方括号是"额外功能"(extras):装 uvicorn 时顺带装上性能更好的可选组件(uvloop、httptools、watchfiles)。这个语法你会经常遇到,比如 pydantic[email]、fastapi[all]。
装完看一眼依赖树:
uv tree --depth 1
真实输出:
Resolved 40 packages in 2ms
notekit v0.1.0
├── fastapi v0.141.1
├── httpx v0.28.1
├── pydantic v2.13.4
├── python-dotenv v1.2.2
├── typer v0.27.1
├── uvicorn[standard] v0.52.3
├── httpx v0.28.1 (group: dev)
├── mypy v2.3.1 (group: dev)
├── pytest v9.1.1 (group: dev)
└── ruff v0.16.3 (group: dev)
我直接声明了 6 个包,实际装了 40 个——其余都是传递依赖。--depth 1 只看直接依赖;去掉它能看到完整的树。
(你跑的时候版本号会更新,这正常。锁文件的意义就是你和别人跑的是同一套版本,见 11.7。)
10.3 第一层:models.py(数据长什么样)
src/notekit/models.py:
"""数据模型:用 Pydantic 定义"一条笔记长什么样"。"""
from datetime import UTC, datetime
from pydantic import BaseModel, Field, field_validator
def _now() -> datetime:
return datetime.now(UTC)
class Note(BaseModel):
"""一条笔记。Pydantic 会在创建时自动校验+转换类型。"""
id: int
title: str = Field(min_length=1, max_length=100)
body: str = ""
tags: list[str] = Field(default_factory=list)
done: bool = False
created_at: datetime = Field(default_factory=_now)
@field_validator("tags")
@classmethod
def strip_tags(cls, v: list[str]) -> list[str]:
"""自定义校验:去空格、去空串、去重,且保持顺序。"""
seen: dict[str, None] = {}
for tag in v:
t = tag.strip()
if t:
seen[t] = None
return list(seen)
class NoteCreate(BaseModel):
"""新建笔记时客户端要传的字段——注意没有 id 和 created_at,那是服务端生成的。"""
title: str = Field(min_length=1, max_length=100)
body: str = ""
tags: list[str] = Field(default_factory=list)
class NoteUpdate(BaseModel):
"""更新时所有字段都可选:只传想改的那几个。
`str | None` 是 Python 3.10+ 的写法,等价于老写法 Optional[str]。
"""
title: str | None = Field(default=None, min_length=1, max_length=100)
body: str | None = None
tags: list[str] | None = None
done: bool | None = None
五个设计决策,每个都有理由:
- 三个模型而不是一个。
Note是完整实体(有 id、有创建时间)、NoteCreate是"客户端能传什么"(没有 id——不能让客户端指定 id)、NoteUpdate是"能改什么"(全部可选)。这是 Web API 的标准做法,避免客户端伪造服务端字段。 default_factory=_now而不是default=_now()。后者会在类定义时求值一次,导致所有笔记的创建时间都一样——这就是 2.10 节可变默认参数陷阱的另一种形态。datetime.now(UTC)而不是datetime.now()。8.1 节实测过 naive 和 aware 不能比较,存 UTC 是唯一不会出错的选择。field_validator里用 dict 当有序集合。seen[t] = None然后list(seen)—— 因为 8.1 节说过 dict 从 3.7 起保持插入顺序,所以这是"去重且保序"的标准惯用法(set会丢顺序)。@field_validator下面必须紧跟@classmethod,顺序不能反。这是 pydantic v2 的要求(v1 不需要,是常见的迁移坑)。
10.4 第二层:store.py(数据存哪里)
src/notekit/store.py:
"""存储层:把笔记存进一个 JSON 文件。只有这一层知道文件在哪。"""
import json
from pathlib import Path
from .models import Note, NoteCreate, NoteUpdate
class NoteStore:
def __init__(self, path: Path):
self.path = path
self._notes: list[Note] = []
self._load()
# ---------- 私有方法:约定用下划线开头 ----------
def _load(self) -> None:
if not self.path.exists():
self._notes = []
return
raw = json.loads(self.path.read_text(encoding="utf-8"))
self._notes = [Note.model_validate(item) for item in raw]
def _save(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
payload = [n.model_dump(mode="json") for n in self._notes]
self.path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
def _next_id(self) -> int:
return max((n.id for n in self._notes), default=0) + 1
# ---------- 公开 API ----------
def add(self, data: NoteCreate) -> Note:
note = Note(id=self._next_id(), **data.model_dump())
self._notes.append(note)
self._save()
return note
def list_all(self, tag: str | None = None, done: bool | None = None) -> list[Note]:
result = self._notes
if tag is not None:
result = [n for n in result if tag in n.tags]
if done is not None:
result = [n for n in result if n.done is done]
return result
def get(self, note_id: int) -> Note | None:
return next((n for n in self._notes if n.id == note_id), None)
def update(self, note_id: int, data: NoteUpdate) -> Note | None:
note = self.get(note_id)
if note is None:
return None
changes = data.model_dump(exclude_unset=True) # 只取调用方真正传了的字段
updated = note.model_copy(update=changes)
Note.model_validate(updated.model_dump()) # 改完再校验一次
self._notes[self._notes.index(note)] = updated
self._save()
return updated
def delete(self, note_id: int) -> bool:
note = self.get(note_id)
if note is None:
return False
self._notes.remove(note)
self._save()
return True
def search(self, keyword: str) -> list[Note]:
kw = keyword.lower()
return [n for n in self._notes if kw in n.title.lower() or kw in n.body.lower()]
这一层有六个值得停下来看的点:
-
model_dump(mode="json")而不是model_dump()。前者把datetime转成字符串,后者留着datetime对象——而json.dumps不认识datetime,会抛TypeError: Object of type datetime is not JSON serializable。这是新手 100% 会撞一次的错。 -
Note(id=..., **data.model_dump())。**字典是"把字典展开成关键字参数",和 3.1 节的**kwargs是一对(一个打包、一个解包)。 -
max((...), default=0):空列表调max()会抛ValueError,default=0是空集合时的兜底。任何对可能为空的集合调max/min都要写default=。 -
next((n for n in ... if ...), None):这是"找第一个满足条件的,找不到返回 None"的标准写法。里面是生成器表达式(6.5 节),所以是短路的——找到就停,不会遍历完整个列表。 -
update用exclude_unset=True——这是 PATCH 语义的核心。 假设调用方只传了{"done": true}: -model_dump()会返回所有字段(title=None, body=None, tags=None, done=True),拿去更新会把标题清空! -model_dump(exclude_unset=True)只返回{"done": True},其余字段保持原值。
exclude_unset 区分的是"显式传了 None"和"根本没传"——这正是 2.5 节说的"要区分 0/None 必须用 is None"在真实业务里的形态。model_copy(update=...) 生成一个改了这几个字段的新对象(不改原对象)。
model_copy不会重新校验,所以后面补一句Note.model_validate(...)。 这是 pydantic 的一个真实陷阱:model_copy(update={"title": ""})会默默地产生一个标题为空的对象,绕过min_length=1。补一次显式校验才安全。
一个我真踩了的坑:这个类的列表方法最初我命名为
list,结果store.py里下面的def search(...) -> list[Note]:直接报TypeError: 'function' object is not subscriptable。原因是类体内部list这个名字已经被方法定义遮住了,list[Note]变成了"给方法对象加下标"。改名成list_all就好了。教训:别用list/dict/type/id/str这些内置名字当方法名或变量名。(顺带一提,id作为 pydantic 字段名是安全的,因为字段名不参与类体的名字解析。)
10.5 第三层:config.py(配置与密钥)
src/notekit/config.py:
"""配置:从环境变量 / .env 读,绝不硬编码。"""
import os
from pathlib import Path
from dotenv import load_dotenv
load_dotenv() # 把 .env 里的键值对塞进 os.environ
def data_file() -> Path:
"""笔记存哪个文件。默认 ./notes.json,可用 NOTEKIT_DATA 覆盖。"""
return Path(os.getenv("NOTEKIT_DATA", "notes.json"))
def api_key() -> str | None:
"""LLM 的 Key。只从环境变量读,代码里一个字符都不留。"""
return os.getenv("LLM_API_KEY")
建 .env(只在本机,绝不提交):
LLM_API_KEY=sk-你的真实密钥
NOTEKIT_DATA=./data/notes.json
立刻把它加进 .gitignore:
echo ".env" >> .gitignore
现在 .gitignore 末尾应该是:
# 密钥绝对不能进版本库
.env
验证读取逻辑(注意:只打印长度和前几位,永远不要把完整密钥打印出来,日志和终端记录都会留痕):
uv run --python 3.12 --with python-dotenv python -c "
import os
from dotenv import load_dotenv
load_dotenv()
k = os.getenv('DEMO_API_KEY')
print('读到了吗:', k is not None)
print('长度:', len(k))
print('前 6 位:', k[:6] + '...')
print('数据文件:', os.getenv('NOTEKIT_DATA'))
print('没配的键返回:', os.getenv('NOT_SET_KEY'))
"
用一个假密钥 DEMO_API_KEY=sk-this-is-a-fake-key-for-teaching 演示,真实输出:
读到了吗: True
长度: 34
前 6 位: sk-thi...
数据文件: /tmp/verify/py/envdemo/notes.json
没配的键返回: None
密钥纪律(这几条是硬性的,前面章节反复强调过):
- 密钥只放
.env或环境变量,代码里一个字符都不留。 .env必须写进.gitignore。 提交历史里的密钥即使后来删了也还在历史里,等于已经泄露。- 前端代码里永远不能出现 LLM 密钥——浏览器里的东西所有人都能看到,必须由后端代理。
- 一旦泄露,立刻去平台吊销并重发,别指望"应该没人看到"。
- 打印/记日志时只打印长度和前缀(像上面那样)。这也是为什么我在这里用了一个假密钥来演示。
os.getenv("KEY", "默认值") 是"没配就用默认值",不传默认值时返回 None。永远不要写 os.environ["KEY"]——那在没配时直接抛 KeyError,错误信息还不告诉你是哪个配置漏了。
10.6 ✅ Checkpoint 10:三层能单独跑通
uv run python -c "
from pathlib import Path
from notekit.models import NoteCreate, NoteUpdate
from notekit.store import NoteStore
s = NoteStore(Path('/tmp/ck10.json'))
a = s.add(NoteCreate(title='第一条', tags=[' py ', 'py', '', 'agent']))
print('id 自增:', a.id, '| 标签去重去空格:', a.tags)
b = s.add(NoteCreate(title='第二条'))
print('第二条 id:', b.id)
u = s.update(a.id, NoteUpdate(done=True))
print('只改 done,标题没丢:', u.done, u.title)
print('按标签过滤:', [n.title for n in s.list_all(tag='py')])
print('重新打开还在:', [n.title for n in NoteStore(Path('/tmp/ck10.json')).list_all()])
"
预期输出(这是设计意图的完整体现):
id 自增: 1 | 标签去重去空格: ['py', 'agent']
第二条 id: 2
只改 done,标题没丢: True 第一条
按标签过滤: ['第一条']
重新打开还在: ['第一条', '第二条']
第三行是关键:update 只传了 done,标题必须还是"第一条"。如果你看到标题变成 None,说明 exclude_unset=True 漏了。
10.7 第四层:cli.py(命令行界面)
src/notekit/cli.py:
"""命令行入口:用 Typer 把函数变成子命令。"""
import typer
from rich.console import Console
from rich.table import Table
from . import config
from .models import NoteCreate, NoteUpdate
from .store import NoteStore
app = typer.Typer(help="notekit —— 一个命令行笔记工具", no_args_is_help=True)
console = Console()
def _store() -> NoteStore:
return NoteStore(config.data_file())
@app.command()
def add(
title: str = typer.Argument(..., help="笔记标题"),
body: str = typer.Option("", "--body", "-b", help="笔记正文"),
tag: list[str] = typer.Option([], "--tag", "-t", help="标签,可重复"),
) -> None:
"""新增一条笔记。"""
note = _store().add(NoteCreate(title=title, body=body, tags=tag))
console.print(f"[green]已添加[/green] #{note.id} {note.title}")
@app.command("list")
def list_notes(
tag: str = typer.Option(None, "--tag", "-t"),
undone: bool = typer.Option(False, "--undone", help="只看未完成"),
) -> None:
"""列出笔记。"""
notes = _store().list_all(tag=tag, done=False if undone else None)
if not notes:
console.print("[yellow]一条笔记都没有[/yellow]")
raise typer.Exit(code=0)
table = Table("ID", "状态", "标题", "标签")
for n in notes:
table.add_row(str(n.id), "✔" if n.done else "…", n.title, ",".join(n.tags))
console.print(table)
@app.command()
def done(note_id: int = typer.Argument(..., help="要标记完成的笔记 ID")) -> None:
"""把一条笔记标为完成。"""
note = _store().update(note_id, NoteUpdate(done=True))
if note is None:
console.print(f"[red]找不到 #{note_id}[/red]")
raise typer.Exit(code=1)
console.print(f"[green]已完成[/green] #{note.id} {note.title}")
@app.command()
def delete(note_id: int) -> None:
"""删除一条笔记。"""
if not _store().delete(note_id):
console.print(f"[red]找不到 #{note_id}[/red]")
raise typer.Exit(code=1)
console.print(f"[green]已删除[/green] #{note_id}")
@app.command()
def search(keyword: str) -> None:
"""按关键词搜索标题和正文。"""
hits = _store().search(keyword)
console.print(f"命中 {len(hits)} 条")
for n in hits:
console.print(f" #{n.id} {n.title} — {n.body[:20]}")
@app.command()
def serve(
host: str = typer.Option("127.0.0.1", help="监听地址,默认只允许本机访问"),
port: int = typer.Option(8000, help="监听端口"),
reload: bool = typer.Option(False, "--reload", help="改代码自动重启,仅开发用"),
) -> None:
"""启动 Web API 服务。"""
import uvicorn
console.print(f"[cyan]http://{host}:{port}/docs[/cyan] 可以看交互式文档")
uvicorn.run("notekit.web:api", host=host, port=port, reload=reload)
def main() -> None:
app()
Typer 的核心思想:函数签名就是命令行接口。 参数名变成选项名、类型注解变成解析规则、docstring 变成帮助文本。你不用写任何"解析 argv"的代码。
| 写法 | 命令行上长什么样 |
|---|---|
typer.Argument(...) |
位置参数,必填:notekit add "标题" |
typer.Option("", "--body", "-b") |
选项,可选:--body "正文" 或 -b "正文" |
tag: list[str] = typer.Option([]) |
可重复选项:-t a -t b → ['a','b'] |
undone: bool = typer.Option(False) |
开关:写了 --undone 就是 True |
@app.command("list") |
命令名和函数名不同(因为 list 是内置名) |
四个细节:
typer.Argument(...)里的...是真实的 Python 对象Ellipsis,Typer 用它表示"必填"。看着奇怪,但这是约定。raise typer.Exit(code=1)是退出并返回非 0 状态码。这对脚本很重要——notekit delete 99 && echo ok不会打印 ok。Android 里你不用管进程退出码,命令行工具必须管。import uvicorn写在serve()函数内部,不在文件顶部。因为 uvicorn 启动慢(要加载一堆东西),放在函数里可以让notekit add这类命令不为它付启动开销。这就是 5.3 节说的延迟导入。@app.command()的顺序决定--help里的显示顺序,不是字母序。
一个 ruff 会报的警告:
typer.Option(...)写在默认值位置,会被 ruff 的B008("不要在默认参数里调用函数")规则拦下——因为 2.10 节那个可变默认参数陷阱正是这条规则要防的。但 Typer 的设计要求这么写。解决办法是在pyproject.toml里为这个文件单独放行:toml [tool.ruff.lint.per-file-ignores] "src/notekit/cli.py" = ["B008"]这是 lint 工具的正确用法:不是关掉规则,而是为有正当理由的地方精确放行。
rich为什么不在dependencies里? 因为它是typer的依赖,被顺带装进来了(uv tree --depth 2能看到typer v0.27.1 → rich v15.0.0)。但直接import一个"别人的依赖"是有风险的——typer 哪天换掉 rich,你的代码就断了。教学项目里这么写是为了少一行,真实项目请把你直接 import 的每个包都显式写进dependencies(uv add rich)。这条规则叫"显式依赖优于传递依赖",和 Gradle 里implementation不该被下游依赖者当 API 用是同一个道理。
还差最后一根线:把 main 接到包上。 pyproject.toml 里写的是 notekit = "notekit:main",也就是"命令 notekit 去调 notekit 包(不是 notekit.cli 模块)里的 main"。而 uv init --package 生成的 src/notekit/__init__.py 里那个 main 还是打印 Hello 的占位版本,要把它换掉:
"""notekit —— 一个命令行 + Web 的笔记工具。"""
__version__ = "0.1.0"
def main() -> None:
from .cli import main as cli_main
cli_main()
两点:
from .cli import ...写在函数里,理由和import uvicorn一样——import notekit只是为了读__version__时,不该顺带把 typer/rich 全加载一遍。__version__在这里定义,§11.6 会讲怎么让它和pyproject.toml里的版本号保持单一来源(现在是两处,这是个已知的隐患)。
跑起来看看。先让数据存到一个临时文件(避免污染当前目录):
export NOTEKIT_DATA=/tmp/ck_cli.json
uv run notekit --help
真实输出(这个漂亮的框是 rich 画的,Typer 自动集成):
Usage: notekit [OPTIONS] COMMAND [ARGS]...
notekit —— 一个命令行笔记工具
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --install-completion Install completion for the current shell. │
│ --show-completion Show completion for the current shell, to copy │
│ it or customize the installation. │
│ --help Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Commands ───────────────────────────────────────────────────────────────────╮
│ add 新增一条笔记。 │
│ list 列出笔记。 │
│ done 把一条笔记标为完成。 │
│ delete 删除一条笔记。 │
│ search 按关键词搜索标题和正文。 │
│ serve 启动 Web API 服务。 │
╰──────────────────────────────────────────────────────────────────────────────╯
--install-completion 是白送的:装上之后按 Tab 能补全子命令。
10.8 ✅ Checkpoint 11:完整走一遍 CLI
export NOTEKIT_DATA=/tmp/ck_cli.json
uv run notekit add "学 Python" --body "从 Android 转过来" -t python -t 学习 -t " python "
uv run notekit add "写个 Agent" -t agent
uv run notekit add "看电影"
uv run notekit list
uv run notekit done 3
uv run notekit list --undone
uv run notekit list --tag python
uv run notekit search android
uv run notekit delete 99; echo "退出码: $?"
真实输出:
已添加 #1 学 Python
已添加 #2 写个 Agent
已添加 #3 看电影
┏━━━━┳━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━┓
┃ ID ┃ 状态 ┃ 标题 ┃ 标签 ┃
┡━━━━╇━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━┩
│ 1 │ … │ 学 Python │ python,学习 │
│ 2 │ … │ 写个 Agent │ agent │
│ 3 │ … │ 看电影 │ │
└────┴──────┴────────────┴─────────────┘
已完成 #3 看电影
┏━━━━┳━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━┓
┃ ID ┃ 状态 ┃ 标题 ┃ 标签 ┃
┡━━━━╇━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━┩
│ 1 │ … │ 学 Python │ python,学习 │
│ 2 │ … │ 写个 Agent │ agent │
└────┴──────┴────────────┴─────────────┘
┏━━━━┳━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━━┓
┃ ID ┃ 状态 ┃ 标题 ┃ 标签 ┃
┡━━━━╇━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━━┩
│ 1 │ … │ 学 Python │ python,学习 │
└────┴──────┴───────────┴─────────────┘
命中 1 条
#1 学 Python — 从 Android 转过来
找不到 #99
退出码: 1
四处要对上的地方:
1. -t python -t 学习 -t " python " 三个标签只留下两个(python,学习)——field_validator 的去空格+去重生效了。
2. done 3 之后 list --undone 只剩 2 条。
3. search android 命中标题里没有 android 的那条——因为它搜正文,而且大小写不敏感(正文是"从 Android 转过来")。
4. delete 99 退出码是 1,不是 0。
再看落盘的 JSON(ensure_ascii=False 的效果——中文是中文,不是 \uXXXX):
cat /tmp/ck_cli.json
[
{
"id": 1,
"title": "学 Python",
"body": "从 Android 转过来",
"tags": [
"python",
"学习"
],
"done": false,
"created_at": "2026-08-16T10:18:01.144424Z"
},
{
"id": 2,
"title": "写个 Agent",
"body": "",
"tags": [
"agent"
],
"done": false,
"created_at": "2026-08-16T10:18:01.270025Z"
},
{
"id": 3,
"title": "看电影",
"body": "",
"tags": [],
"done": true,
"created_at": "2026-08-16T10:18:01.392051Z"
}
]
校验也生效了。 试着加一条空标题的:
uv run notekit add ""
Typer/rich 会打印一大段带框的 traceback,最后是关键的几行:
ValidationError: 1 validation error for NoteCreate
title
String should have at least 1 character [type=string_too_short,
input_value='', input_type=str]
For further information visit
https://errors.pydantic.dev/2.13/v/string_too_short
这个未捕获异常暴露了一个真实的产品缺陷:给用户看 traceback 是很差的体验。生产级的做法是在 add 里捕获它:
from pydantic import ValidationError
try:
note = _store().add(NoteCreate(title=title, body=body, tags=tag))
except ValidationError as e:
console.print(f"[red]参数不对:[/red]{e.errors()[0]['msg']}")
raise typer.Exit(code=2) from e
e.errors() 返回一个字典列表,每项有 type/loc/msg/input——就是 Checkpoint 9 里那些 string_too_short 的来源。这也是下一节 HTTP 422 响应体的内容。(我在示例项目里故意没加这个 try,好让你亲眼看见"不处理会怎样"。)
10.9 第五层:web.py(同一套逻辑,换成 HTTP)
src/notekit/web.py:
"""Web 层:用 FastAPI 把同一套业务逻辑再暴露成 HTTP 接口。"""
import asyncio
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, Query
from . import config
from .models import Note, NoteCreate, NoteUpdate
from .store import NoteStore
api = FastAPI(title="notekit API", version="0.1.0")
def get_store() -> NoteStore:
"""依赖注入:FastAPI 会在每次请求时调用它,把返回值塞进接口参数。
测试时可以整体替换掉它 —— 见 tests/test_api.py。
"""
return NoteStore(config.data_file())
# Annotated[类型, Depends(...)] 是 FastAPI 现在推荐的写法:
# 比 `store: NoteStore = Depends(get_store)` 更好,因为默认值位置留给了真正的默认值。
StoreDep = Annotated[NoteStore, Depends(get_store)]
@api.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@api.get("/slow")
async def slow(seconds: Annotated[float, Query(le=2.0)] = 0.3) -> dict[str, float]:
"""故意慢的接口,用来模拟"调一次大模型要等几百毫秒"。"""
await asyncio.sleep(seconds)
return {"waited": seconds}
@api.get("/notes", response_model=list[Note])
def list_notes(
store: StoreDep,
tag: Annotated[str | None, Query(description="按标签过滤")] = None,
done: Annotated[bool | None, Query(description="按完成状态过滤")] = None,
) -> list[Note]:
return store.list_all(tag=tag, done=done)
@api.post("/notes", response_model=Note, status_code=201)
def create_note(data: NoteCreate, store: StoreDep) -> Note:
return store.add(data)
@api.get("/notes/{note_id}", response_model=Note)
def get_note(note_id: int, store: StoreDep) -> Note:
note = store.get(note_id)
if note is None:
raise HTTPException(status_code=404, detail=f"没有 id={note_id} 的笔记")
return note
@api.patch("/notes/{note_id}", response_model=Note)
def update_note(note_id: int, data: NoteUpdate, store: StoreDep) -> Note:
note = store.update(note_id, data)
if note is None:
raise HTTPException(status_code=404, detail=f"没有 id={note_id} 的笔记")
return note
@api.delete("/notes/{note_id}", status_code=204)
def delete_note(note_id: int, store: StoreDep) -> None:
if not store.delete(note_id):
raise HTTPException(status_code=404, detail=f"没有 id={note_id} 的笔记")
只有 70 行,但你已经拿到了一套完整的 REST API + 自动生成的交互式文档 + 自动的参数校验。 这是 FastAPI 最值钱的地方:你的类型注解同时是校验规则和 API 文档。
对照 Retrofit 理解:Retrofit 里你写接口声明、它生成客户端;FastAPI 里你写函数、它生成服务端 + OpenAPI 文档(相当于自动生成的 Retrofit 接口定义)。
FastAPI 怎么知道每个参数从哪来?靠类型:
| 参数写法 | FastAPI 从哪取值 |
|---|---|
note_id: int(且路径里有 {note_id}) |
路径参数 |
data: NoteCreate(是个 BaseModel) |
请求体 JSON |
tag: Annotated[str \| None, Query(...)] |
查询字符串 ?tag=x |
store: StoreDep(带 Depends) |
依赖注入,调 get_store() |
四个关键设计:
-
Annotated[NoteStore, Depends(get_store)]起个别名StoreDep。 这样每个接口只写store: StoreDep一次。依赖注入的价值在测试:测试时把get_store换成"返回临时目录里的 store",业务代码一行都不用改(下一节就用到)。这就是 Android 里的 Hilt/Dagger,但不需要注解处理器和代码生成。 -
response_model=Note声明返回什么。FastAPI 会按这个模型过滤返回值——即使你的函数多返回了字段,也不会泄露给客户端。这是一层安全网(比如你的内部模型多了个password_hash字段)。 -
status_code=201/204。REST 约定:201 = 创建成功、204 = 删除成功且无返回体。不写默认是 200。 -
raise HTTPException(404, detail=...)而不是return {"error": ...}。 抛异常能从任意深度的调用栈里直接跳出并返回正确状态码,不用层层if往上传。
Query(le=2.0) 是校验器:le = less or equal。传 ?seconds=99 会被自动拒绝,返回 422——你没写一行校验代码。
启动它:
export NOTEKIT_DATA=/tmp/ck_http.json
uv run notekit serve --port 8124
真实输出:
http://127.0.0.1:8124/docs 可以看交互式文档
INFO: Started server process [27506]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8124 (Press CTRL+C to quit)
浏览器打开 http://127.0.0.1:8124/docs,你会看到一个可以直接点"Try it out"发请求的页面。这是 FastAPI 从你的类型注解自动生成的(Swagger UI),一行配置都没写。
安全提醒:
serve的默认host是127.0.0.1,只有本机能访问。这是故意的。如果你改成0.0.0.0,同一个局域网里的任何人都能读写你的笔记——而这套 API 完全没有任何认证。真要对外提供服务,必须先加上认证(FastAPI 的OAuth2PasswordBearer或最简单的 API Key 依赖),并放在 HTTPS 后面。第 12 节在容器里用0.0.0.0是因为容器网络是隔离的,且只通过 compose 映射到宿主机的127.0.0.1。
10.10 ✅ Checkpoint 12:用 curl 走完整个 API
另开一个终端(第一个还在跑服务),逐条发请求:
B=http://127.0.0.1:8124
curl -s $B/health; echo
curl -s -X POST $B/notes -H 'Content-Type: application/json' \
-d '{"title":"用 curl 建的","body":"HTTP 也能操作同一份数据","tags":["api"," api ","web"]}' \
-w ' <- HTTP %{http_code}\n'
curl -s -X POST $B/notes -H 'Content-Type: application/json' -d '{"title":"第二条"}' -w ' <- HTTP %{http_code}\n'
curl -s $B/notes; echo
curl -s -X PATCH $B/notes/1 -H 'Content-Type: application/json' -d '{"done":true}' -w ' <- HTTP %{http_code}\n'
curl -s "$B/notes?tag=web&done=true"; echo
curl -s $B/notes/99999 -w ' <- HTTP %{http_code}\n'
curl -s -X POST $B/notes -H 'Content-Type: application/json' -d '{"title":""}' -w ' <- HTTP %{http_code}\n'
curl -s "$B/slow?seconds=99" -w ' <- HTTP %{http_code}\n'
curl -s -X DELETE $B/notes/2 -w 'HTTP %{http_code}\n'
curl -s -X DELETE $B/notes/2 -w ' <- HTTP %{http_code}\n'
curl -s $B/openapi.json | python3 -c "import json,sys; print(sorted(json.load(sys.stdin)['paths'].keys()))"
真实输出:
{"status":"ok"}
{"id":1,"title":"用 curl 建的","body":"HTTP 也能操作同一份数据","tags":["api","web"],"done":false,"created_at":"2026-08-16T10:20:11.109647Z"} <- HTTP 201
{"id":2,"title":"第二条","body":"","tags":[],"done":false,"created_at":"2026-08-16T10:20:11.117481Z"} <- HTTP 201
[{"id":1,"title":"用 curl 建的","body":"HTTP 也能操作同一份数据","tags":["api","web"],"done":false,"created_at":"2026-08-16T10:20:11.109647Z"},{"id":2,"title":"第二条","body":"","tags":[],"done":false,"created_at":"2026-08-16T10:20:11.117481Z"}]
{"id":1,"title":"用 curl 建的","body":"HTTP 也能操作同一份数据","tags":["api","web"],"done":true,"created_at":"2026-08-16T10:20:11.109647Z"} <- HTTP 200
[{"id":1,"title":"用 curl 建的","body":"HTTP 也能操作同一份数据","tags":["api","web"],"done":true,"created_at":"2026-08-16T10:20:11.109647Z"}]
{"detail":"没有 id=99999 的笔记"} <- HTTP 404
{"detail":[{"type":"string_too_short","loc":["body","title"],"msg":"String should have at least 1 character","input":"","ctx":{"min_length":1}}]} <- HTTP 422
{"detail":[{"type":"less_than_equal","loc":["query","seconds"],"msg":"Input should be less than or equal to 2","input":"99","ctx":{"le":2.0}}]} <- HTTP 422
HTTP 204
{"detail":"没有 id=2 的笔记"} <- HTTP 404
['/health', '/notes', '/notes/{note_id}', '/slow']
八处必须对上的地方:
- POST 返回 201,不是 200。
["api"," api ","web"]变成了["api","web"]——models.py里那个field_validator在 HTTP 层同样生效。这就是"业务逻辑只写一遍"的兑现:CLI 和 HTTP 走的是同一个模型。- PATCH 只传
done,标题和正文都还在——exclude_unset=True生效。 ?tag=web&done=true组合过滤生效,done=true这个字符串被自动转成了 Python 的True。- 404 的 detail 是我写的中文消息。
- 422 的响应体是结构化的,
loc: ["body","title"]精确指出"请求体里的 title 字段"。注意这和 Checkpoint 9 里 pydantic 直接抛的string_too_short是同一个东西——FastAPI 只是把它包装成了 HTTP 响应。客户端可以按type字段分支处理。 ?seconds=99也是 422,loc: ["query","seconds"]——Query(le=2.0)这一个声明就换来了校验 + 报错 + 文档。- DELETE 成功是 204 且没有响应体,再删同一个变 404。
/openapi.json 里有全部 4 个路径的机器可读描述。这个文件可以直接喂给代码生成器,给 Android 端生成 Retrofit 接口——这就是 OpenAPI 的意义。
跑完记得回第一个终端按 Ctrl+C 停掉服务。
10.11 测试:pytest
Android 里你写 JUnit,Python 里写 pytest。pytest 比 JUnit 简单得多:函数名以 test_ 开头、用普通的 assert,没有注解、没有类、没有 assertEquals。
tests/test_store.py:
from pathlib import Path
import pytest
from pydantic import ValidationError
from notekit.models import NoteCreate, NoteUpdate
from notekit.store import NoteStore
@pytest.fixture
def store(tmp_path: Path) -> NoteStore:
"""fixture = 每个测试跑之前自动准备好的东西。
tmp_path 是 pytest 内置的临时目录,测试结束自动清理——不会污染你的真实数据。
"""
return NoteStore(tmp_path / "notes.json")
def test_add_assigns_incrementing_id(store: NoteStore):
a = store.add(NoteCreate(title="第一条"))
b = store.add(NoteCreate(title="第二条"))
assert a.id == 1
assert b.id == 2
def test_tags_are_stripped_and_deduped(store: NoteStore):
note = store.add(NoteCreate(title="标签测试", tags=[" py ", "py", "", "agent"]))
assert note.tags == ["py", "agent"]
def test_empty_title_is_rejected():
with pytest.raises(ValidationError):
NoteCreate(title="")
def test_data_survives_reload(tmp_path: Path):
path = tmp_path / "notes.json"
NoteStore(path).add(NoteCreate(title="持久化"))
reopened = NoteStore(path)
assert [n.title for n in reopened.list_all()] == ["持久化"]
def test_update_only_touches_given_fields(store: NoteStore):
note = store.add(NoteCreate(title="原标题", body="原正文"))
updated = store.update(note.id, NoteUpdate(done=True))
assert updated is not None
assert updated.done is True
assert updated.title == "原标题" # 没传的字段不能被清空
assert updated.body == "原正文"
def test_update_missing_id_returns_none(store: NoteStore):
assert store.update(999, NoteUpdate(done=True)) is None
def test_delete(store: NoteStore):
note = store.add(NoteCreate(title="待删"))
assert store.delete(note.id) is True
assert store.delete(note.id) is False
assert store.list_all() == []
def test_filter_by_tag_and_done(store: NoteStore):
store.add(NoteCreate(title="A", tags=["x"]))
b = store.add(NoteCreate(title="B", tags=["x", "y"]))
store.add(NoteCreate(title="C", tags=["y"]))
store.update(b.id, NoteUpdate(done=True))
assert [n.title for n in store.list_all(tag="x")] == ["A", "B"]
assert [n.title for n in store.list_all(done=False)] == ["A", "C"]
assert [n.title for n in store.list_all(tag="x", done=True)] == ["B"]
@pytest.mark.parametrize(
"keyword,expected",
[("python", ["学 Python"]), ("PYTHON", ["学 Python"]), ("正文", ["带正文的"]), ("不存在", [])],
)
def test_search_is_case_insensitive(store: NoteStore, keyword, expected):
store.add(NoteCreate(title="学 Python"))
store.add(NoteCreate(title="带正文的", body="这里有正文两个字"))
assert [n.title for n in store.search(keyword)] == expected
pytest 的四个核心概念:
| 概念 | 是什么 | Android 对应 |
|---|---|---|
test_xxx 函数 |
一个测试用例 | @Test fun |
assert x == y |
断言,失败时 pytest 自动打印两边的实际值 | assertEquals |
@pytest.fixture |
提供"预备好的对象",参数名匹配即注入 | @Before + 依赖注入 |
@pytest.mark.parametrize |
一个函数跑多组数据 | @ParameterizedTest |
fixture 的注入靠参数名:def test_delete(store: NoteStore) 里的参数叫 store,pytest 就去找名叫 store 的 fixture 调用它,把返回值传进来。不需要任何注解或注册——这是 pytest 最反直觉也最好用的设计。
tmp_path 是白送的:pytest 内置 fixture,给每个测试一个独立的空临时目录,跑完自动清理。注意 test_data_survives_reload 直接要了 tmp_path 而没要 store——fixture 可以按需组合,谁要什么写什么。测试里读写文件绝不要用固定路径,会互相污染。
assert 的魔法:pytest 重写了 assert 语句,失败时会打印 assert 1 == 2 两边的真实值,不需要你写消息。这比 JUnit 的 assertEquals(expected, actual) 参数顺序困扰要省心。
with pytest.raises(ValidationError): 断言"这段代码必须抛这个异常",相当于 JUnit 的 assertThrows。如果没抛,测试失败——这是测校验逻辑的正确姿势。
注意测试函数没写返回类型 -> None,而源码里每个函数都写了。这不是疏忽:[tool.mypy] 只在跑 mypy src 时检查 src 目录,测试目录不在检查范围内。这是一个常见的实用取舍——测试代码的类型严格度可以低一些,换取写起来快。
tests/test_api.py——这里体现依赖注入的价值:
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from notekit.store import NoteStore
from notekit.web import api, get_store
@pytest.fixture
def client(tmp_path: Path) -> TestClient:
"""把 get_store 换成指向临时目录的版本 —— 测试不碰你的真实 notes.json。"""
store = NoteStore(tmp_path / "notes.json")
api.dependency_overrides[get_store] = lambda: store
yield TestClient(api)
api.dependency_overrides.clear()
def test_health(client: TestClient):
r = client.get("/health")
assert r.status_code == 200
assert r.json() == {"status": "ok"}
def test_create_and_list(client: TestClient):
r = client.post("/notes", json={"title": "第一条", "tags": ["py"]})
assert r.status_code == 201
body = r.json()
assert body["id"] == 1
assert body["done"] is False
r = client.get("/notes")
assert [n["title"] for n in r.json()] == ["第一条"]
def test_validation_error_returns_422(client: TestClient):
r = client.post("/notes", json={"title": ""})
assert r.status_code == 422
assert r.json()["detail"][0]["loc"] == ["body", "title"]
def test_missing_field_returns_422(client: TestClient):
r = client.post("/notes", json={"body": "没有标题"})
assert r.status_code == 422
def test_get_404(client: TestClient):
r = client.get("/notes/999")
assert r.status_code == 404
assert "没有 id=999" in r.json()["detail"]
def test_patch_partial(client: TestClient):
client.post("/notes", json={"title": "原标题", "body": "原正文"})
r = client.patch("/notes/1", json={"done": True})
assert r.status_code == 200
assert r.json()["done"] is True
assert r.json()["title"] == "原标题"
def test_delete_then_404(client: TestClient):
client.post("/notes", json={"title": "待删"})
assert client.delete("/notes/1").status_code == 204
assert client.delete("/notes/1").status_code == 404
def test_filter_query_params(client: TestClient):
client.post("/notes", json={"title": "A", "tags": ["x"]})
client.post("/notes", json={"title": "B", "tags": ["y"]})
client.patch("/notes/2", json={"done": True})
assert [n["title"] for n in client.get("/notes?tag=x").json()] == ["A"]
assert [n["title"] for n in client.get("/notes?done=true").json()] == ["B"]
四个要点:
TestClient(api)不启动真实服务器、不开端口、不走网络,直接在进程内调用你的接口函数。所以 8 个 HTTP 测试总共只花几十毫秒。对照 Android:这相当于用 MockWebServer,但连 mock server 都不用起。api.dependency_overrides[get_store] = lambda: store就是"把依赖换掉"。业务代码一个字都不改,测试就用上了临时文件——这正是 10.9 节说的依赖注入的兑现。如果没有Depends,web.py里会直接NoteStore(config.data_file()),测试就只能靠改环境变量绕,脆弱得多。yield前是准备、yield后是清理(相当于 JUnit 的@After)。必须清理dependency_overrides:它是挂在全局api对象上的字典,不清就会泄漏到后面的测试。这是"fixture 里改了全局状态就必须还原"的通例。test_validation_error_returns_422断言的是loc == ["body","title"]——直接对上了 Checkpoint 12 里 curl 看到的响应体。测试和手动验证看到的是同一个东西,这才叫测试可信。
10.12 三道质量门
先看完整的 pyproject.toml——这就是最终成品,一个文件管住依赖、入口、打包、三个工具的全部配置:
[project]
name = "notekit"
version = "0.1.0"
description = "一个命令行 + Web 的笔记工具"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115",
"httpx>=0.28.1",
"pydantic>=2.13.4",
"python-dotenv>=1.2.2",
"typer>=0.27.1",
"uvicorn[standard]>=0.30",
]
[project.scripts]
notekit = "notekit:main"
[build-system]
requires = ["uv_build>=0.11.32,<0.12.0"]
build-backend = "uv_build"
[dependency-groups]
dev = [
"httpx>=0.28.1",
"mypy>=2.3.1",
"pytest>=9.1.1",
"ruff>=0.16.3",
]
[tool.ruff]
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]
[tool.ruff.lint.per-file-ignores]
# Typer 的设计就是把 typer.Option(...) 放在默认参数里,B008 在这里是误报
"src/notekit/cli.py" = ["B008"]
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.mypy]
python_version = "3.12"
warn_return_any = true
对照 Android 的感受:Gradle 里这些配置要散在 build.gradle.kts、gradle.properties、detekt.yml、.editorconfig 好几个文件里。Python 现在的方向是全部收进 pyproject.toml([tool.xxx] 是各工具约定的命名空间)。少数工具还有自己的配置文件,但主流工具都支持这里。
select 里每个字母是一组规则:
| 代号 | 管什么 | 例子 |
|---|---|---|
E |
代码风格(pycodestyle) | 行太长、空格不对 |
F |
真实错误(pyflakes) | 用了没定义的变量、导入了没用的包 |
I |
import 排序(isort) | 标准库/第三方/本地分组并排序 |
UP |
用新语法(pyupgrade) | List[str] → list[str] |
B |
常见 bug(bugbear) | 可变默认参数、循环里的闭包陷阱 |
SIM |
能写更简单(simplify) | if x: return True else: return False |
F 和 B 是最值钱的两组——它们抓的是真 bug,不是风格。I 让你再也不用手动整理 import。ruff 还有几十组规则可选(ANN 强制类型注解、S 安全检查 bandit、PT pytest 风格……),建议从这 6 组起步,跑顺了再逐个加——一次全开会得到几百个报错然后放弃。
warn_return_any = true 是 mypy 最该开的一项:函数声明返回 int 却实际返回了 Any(比如从 json.loads() 出来的东西)时报警。Any 会像病毒一样传播,它经过的地方类型检查全部失效,这个开关能挡住它。
三条命令:
uv run ruff check . # 语法/风格/常见 bug —— ktlint + detekt
uv run mypy src # 类型检查 —— Kotlin 编译器帮你做的那部分
uv run pytest -q # 测试 —— JUnit
顺手还能自动修:
uv run ruff check --fix . # 自动修能修的(比如 import 排序)
uv run ruff format . # 格式化 —— 相当于 ktlintFormat
10.13 ✅ Checkpoint 13:三道门全绿
uv run ruff check .
uv run mypy src
uv run pytest -q
真实输出:
All checks passed!
Success: no issues found in 6 source files
.................... [100%]
20 passed, 1 warning in 0.22s
只有最后那个耗时数字会和你不一样(我在同一台机器上反复跑,0.18s / 0.22s / 0.23s 都出现过)。要一字不差对上的是前三行和
20 passed, 1 warning——20和1这两个数字必须相同,否则说明你少写了测试或多出了警告。
这三行是你以后每次提交前都要看到的东西。 建议直接串起来当一条命令:
uv run ruff check . && uv run mypy src && uv run pytest -q
&& 保证前一步失败就不继续——这就是 10.7 节说的"退出码要正确"在真实工作流里的用途。
想看每个测试跑了什么,加 -v(真实输出):
cachedir: .pytest_cache
rootdir: /private/tmp/verify/py/notekit
configfile: pyproject.toml
testpaths: tests
plugins: anyio-4.14.2
collecting ... collected 20 items
tests/test_api.py::test_health PASSED [ 5%]
tests/test_api.py::test_create_and_list PASSED [ 10%]
tests/test_api.py::test_validation_error_returns_422 PASSED [ 15%]
tests/test_api.py::test_missing_field_returns_422 PASSED [ 20%]
tests/test_api.py::test_get_404 PASSED [ 25%]
tests/test_api.py::test_patch_partial PASSED [ 30%]
tests/test_api.py::test_delete_then_404 PASSED [ 35%]
tests/test_api.py::test_filter_query_params PASSED [ 40%]
tests/test_store.py::test_add_assigns_incrementing_id PASSED [ 45%]
tests/test_store.py::test_tags_are_stripped_and_deduped PASSED [ 50%]
tests/test_store.py::test_empty_title_is_rejected PASSED [ 55%]
tests/test_store.py::test_data_survives_reload PASSED [ 60%]
tests/test_store.py::test_update_only_touches_given_fields PASSED [ 65%]
tests/test_store.py::test_update_missing_id_returns_none PASSED [ 70%]
tests/test_store.py::test_delete PASSED [ 75%]
tests/test_store.py::test_filter_by_tag_and_done PASSED [ 80%]
tests/test_store.py::test_search_is_case_insensitive[python-expected0] PASSED [ 85%]
tests/test_store.py::test_search_is_case_insensitive[PYTHON-expected1] PASSED [ 90%]
tests/test_store.py::test_search_is_case_insensitive[正文-expected2] PASSED [ 95%]
tests/test_store.py::test_search_is_case_insensitive[不存在-expected3] PASSED [100%]
三个值得注意的细节:
test_search_is_case_insensitive[python-expected0]这种带方括号的名字:parametrize的每组数据都是一个独立测试,方括号里是参数值。16 个测试函数变成了 20 个用例,这就是为什么数字是 20。你可以只跑其中一个:bash uv run pytest "tests/test_store.py::test_search_is_case_insensitive[PYTHON-expected1]"- 中文参数在测试 ID 里显示成
正文——pytest 对非 ASCII 参数会转义。想让 ID 好看,给 parametrize 加ids=参数:@pytest.mark.parametrize(..., ids=["小写","大写","搜正文","搜不到"])。 expected0/1/2/3是自动编号:因为第二个参数是列表(不是简单标量),pytest 没法用它生成可读名字,就用序号代替。
那 1 个 warning 是什么? 别忽略警告,看一眼完整内容:
=============================== warnings summary ===============================
.venv/lib/python3.12/site-packages/fastapi/testclient.py:1
.../fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with
`starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
这是"依赖的新版本改了推荐用法"的提示,不影响正确性,而且发生在 fastapi 内部(不是我的代码)。处理原则:警告要读、要记下来,但不必立刻改——除非它说的是"下个大版本会删掉"。真要消掉,按提示装 httpx2。
如果想让警告变成硬性失败(成熟项目常这么做),在 pyproject.toml 加:
[tool.pytest.ini_options]
testpaths = ["tests"]
filterwarnings = ["error"] # 任何警告都当错误
但对这个项目会立刻挂——因为警告来自第三方库,你改不了。所以更实用的是精确忽略:filterwarnings = ["error", "ignore::DeprecationWarning:starlette.*"]。这也是一条通用原则:全局收紧 + 精确放行,而不是全局放松。
十一、发布:把项目变成"别人能装的东西"
Android 里你把代码打成 .apk 传应用商店,或打成 .aar 传 Maven。Python 里你把代码打成 .whl(wheel) 传 PyPI。这一节走完从"我本地能跑"到"全世界 pip install 就能用"的整条路。
| Android | Python |
|---|---|
.aar / .jar |
.whl(wheel) —— 装好即用的二进制包 |
| 源码 zip | .tar.gz(sdist) —— 源码包,装的时候要现场构建 |
| Maven Central / jitpack | PyPI(pypi.org) |
./gradlew assembleRelease |
uv build |
./gradlew publish |
uv publish / twine upload |
gradle/libs.versions.toml + gradle.lockfile |
pyproject.toml + uv.lock |
11.1 uv build:打包
uv build
真实输出:
Building source distribution (uv build backend)...
Building wheel from source distribution (uv build backend)...
Successfully built dist/notekit-0.1.0.tar.gz
Successfully built dist/notekit-0.1.0-py3-none-any.whl
ls -l dist
-rw-r--r-- 1 c wheel 7085 Aug 16 18:23 notekit-0.1.0-py3-none-any.whl
-rw-r--r-- 1 c wheel 4897 Aug 16 18:23 notekit-0.1.0.tar.gz
两个产物,两种用途:
- wheel(
.whl):装的时候直接解压就完事,快。文件名notekit-0.1.0-py3-none-any.whl是包名-版本-Python标签-ABI标签-平台标签: py3= 任何 Python 3 都能用none= 不依赖特定的 C ABIany= 任何操作系统/CPU 架构py3-none-any叫"纯 Python 包",一个文件通吃全平台。对比 numpy 的 wheel 会长成numpy-2.5.2-cp312-cp312-macosx_11_0_arm64.whl——里面有编译好的 C 代码,所以要区分 Python 版本、操作系统和 CPU。- sdist(
.tar.gz):源码包。当某个平台没有现成 wheel 时,pip 会下 sdist 然后现场构建。这就是为什么有时pip install会突然开始编译半小时。
11.2 拆开 wheel 看里面到底装了什么
wheel 本质就是一个 zip,可以直接看:
unzip -l dist/notekit-0.1.0-py3-none-any.whl
真实输出:
Length Date Time Name
--------- ---------- ----- ----
0 01-01-1980 00:00 notekit/
161 01-01-1980 00:00 notekit/__init__.py
2907 01-01-1980 00:00 notekit/cli.py
510 01-01-1980 00:00 notekit/config.py
1501 01-01-1980 00:00 notekit/models.py
2473 01-01-1980 00:00 notekit/store.py
2532 01-01-1980 00:00 notekit/web.py
0 01-01-1980 00:00 notekit-0.1.0.dist-info/
81 01-01-1980 00:00 notekit-0.1.0.dist-info/WHEEL
42 01-01-1980 00:00 notekit-0.1.0.dist-info/entry_points.txt
358 01-01-1980 00:00 notekit-0.1.0.dist-info/METADATA
737 01-01-1980 00:00 notekit-0.1.0.dist-info/RECORD
--------- -------
11302 12 files
四个要注意的点:
tests/没有进去,.env也没有。 这是 src-layout + 默认规则的好处:只有src/notekit/下的东西被打包。测试代码不该发给用户,密钥更不该。src/这一层在包里消失了——装到用户机器上就是site-packages/notekit/。src/只是开发时的目录约定。- 所有文件的时间戳都是
01-01-1980 00:00。这不是 bug,是可复现构建(reproducible build):同样的源码,任何人任何时候打出的 wheel 字节完全一致,能算出同样的哈希。zip 格式最早支持的时间就是 1980 年,所以取它当固定值。 dist-info/里的四个文件是包的"身份证":
entry_points.txt——这是命令行命令的来源:
[console_scripts]
notekit = notekit:main
METADATA——依赖声明就在这里,pip 靠它决定还要装什么:
Metadata-Version: 2.3
Name: notekit
Version: 0.1.0
Summary: 一个命令行 + Web 的笔记工具
Requires-Dist: fastapi>=0.115
Requires-Dist: httpx>=0.28.1
Requires-Dist: pydantic>=2.13.4
Requires-Dist: python-dotenv>=1.2.2
Requires-Dist: typer>=0.27.1
Requires-Dist: uvicorn[standard]>=0.30
Requires-Python: >=3.12
Description-Content-Type: text/markdown
注意 Requires-Dist 里是 >= 范围,不是精确版本。 这是一条重要区别:
库(library)声明宽松范围,应用(application)用锁文件钉死。
如果你的 wheel 里写死
pydantic==2.13.4,任何同时用到别的 pydantic 版本的项目都装不上你——版本冲突地狱。所以pyproject.toml的dependencies写>=。而uv.lock记录精确版本,只用于你自己开发和部署,不进 wheel。
WHEEL——描述打包格式:
Wheel-Version: 1.0
Generator: uv 0.11.32
Root-Is-Purelib: true
Tag: py3-none-any
RECORD 是文件清单 + 每个文件的哈希,卸载时靠它知道该删哪些文件。
再看 sdist 里有什么(多了 pyproject.toml 和 README.md,因为要能重新构建):
tar -tzf dist/notekit-0.1.0.tar.gz
notekit-0.1.0/PKG-INFO
notekit-0.1.0/
notekit-0.1.0/README.md
notekit-0.1.0/pyproject.toml
notekit-0.1.0/src
notekit-0.1.0/src/notekit/__init__.py
notekit-0.1.0/src/notekit/cli.py
notekit-0.1.0/src/notekit/config.py
notekit-0.1.0/src/notekit/models.py
notekit-0.1.0/src/notekit/store.py
notekit-0.1.0/src/notekit/web.py
想控制打包内容,在
pyproject.toml里配[tool.uv.build-backend](不同后端配置项不同,比如 hatchling 用[tool.hatch.build])。打包完一定要unzip -l看一眼——"忘了把数据文件打进去"是发布环节最常见的 bug,而且只有用户会撞到。
11.3 ✅ Checkpoint 14:在一个全新环境里装上自己的包
这一步是真正的验证:新建一个干净的虚拟环境,只用那个 wheel 文件安装,看它能不能跑。
cd /tmp/verify/py
uv venv fresh --python 3.12
uv pip install --python fresh/bin/python notekit/dist/notekit-0.1.0-py3-none-any.whl
真实输出(截取头尾):
Using CPython 3.12.13 interpreter at: /opt/homebrew/opt/python@3.12/bin/python3.12
Creating virtual environment at: fresh
Activate with: source fresh/bin/activate
Using Python 3.12.13 environment at: fresh
Resolved 29 packages in 1.55s
Prepared 1 package in 45ms
Installed 29 packages in 48ms
+ annotated-doc==0.0.5
+ annotated-types==0.8.0
+ anyio==4.14.2
...
+ notekit==0.1.0 (from file:///private/tmp/verify/py/notekit/dist/notekit-0.1.0-py3-none-any.whl)
...
+ uvloop==0.22.1
+ watchfiles==1.2.0
+ websockets==17.0.1
我只装了 1 个 wheel,pip 却装了 29 个包——它读了 METADATA 里的 Requires-Dist,递归把依赖全解决了。注意 29 < 40:因为 dev 依赖(pytest/ruff/mypy)没有进 wheel,--dev 起作用了。
命令行命令也自动出现了:
ls fresh/bin/ | grep -i note
notekit
这就是 entry_points.txt 的效果:安装器读到 [console_scripts] notekit = notekit:main,就在 bin/ 下生成一个可执行脚本。真的跑一下:
NOTEKIT_DATA=/tmp/fresh.json ./fresh/bin/notekit add "从 wheel 装的" -t 发布
NOTEKIT_DATA=/tmp/fresh.json ./fresh/bin/notekit list
已添加 #1 从 wheel 装的
┏━━━━┳━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━┓
┃ ID ┃ 状态 ┃ 标题 ┃ 标签 ┃
┡━━━━╇━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━┩
│ 1 │ … │ 从 wheel 装的 │ 发布 │
└────┴──────┴───────────────┴──────┘
再确认它加载的确实是装进 site-packages 的那份,不是当前目录的源码:
cd /tmp && /tmp/verify/py/fresh/bin/python -c "import notekit; print(notekit.__version__); print(notekit.__file__)"
0.1.0
/private/tmp/verify/py/fresh/lib/python3.12/site-packages/notekit/__init__.py
路径里有 site-packages 就说明成功了。 我特意先 cd /tmp 再跑——如果在项目目录里跑,__file__ 可能指向 src/notekit/(5.1 节说过 sys.path 里有当前目录),那就验证不了打包是否正确。这是"src-layout 让你提前发现打包错误"的兑现方式。
一个坑,我真撞了:我一开始想用
./fresh/bin/python -m pip install ...,结果报/private/tmp/verify/py/fresh/bin/python: No module named pipuv venv建的虚拟环境默认不装 pip(因为 uv 自己就管安装,省时间省空间)。解决办法二选一:用uv pip install --python fresh/bin/python ...(推荐,快得多),或者建环境时加uv venv fresh --seed把 pip 装进去。
11.4 uv tool install:把 CLI 装成全局命令
上面那样装进虚拟环境,只有激活那个环境才能用。如果你的包是个命令行工具,你想在任何地方都能敲 notekit,用 uv tool:
uv tool install --force .
Installed 1 executable: notekit
which notekit
/Users/c/.local/bin/notekit
uv tool list
notekit v0.1.0
- notekit
uv tool 的原理:给每个工具建一个独立隐藏的虚拟环境,然后只把可执行文件软链到 ~/.local/bin。所以工具之间的依赖永远不会互相冲突——ruff 要 click 8、notekit 要 click 9,各自装各自的,互不干扰。
这就是 1.x 节说过的 uvx/uv tool run 的持久化版本:
| 命令 | 什么时候用 |
|---|---|
uvx ruff check . |
临时跑一次,不留痕迹 |
uv tool install ruff |
天天用,装成全局命令 |
uv add --dev ruff |
项目内用,版本随项目锁定(推荐) |
卸载:
uv tool uninstall notekit
Uninstalled 1 executable: notekit
对照 Android:这相当于把一个工具装进 ~/.gradle 全局可用,而不是每个项目 build.gradle 里声明一遍。给团队用的内部 CLI 工具,uv tool install 是最省心的分发方式——不用打包传 PyPI,直接 uv tool install git+https://内部仓库地址 就行。
11.5 发到 PyPI
先做四件事,一件都别漏:
- 改包名。PyPI 上包名全球唯一,
notekit大概率被占了。先去 pypi.org 搜一下。 - 补全
pyproject.toml的元信息——没有这些,你的包在 PyPI 页面上会很难看,也搜不到:
[project]
name = "notekit-你的后缀"
version = "0.1.0"
description = "一个命令行 + Web 的笔记工具"
readme = "README.md"
requires-python = ">=3.12"
license = "MIT"
authors = [{ name = "你的名字", email = "you@example.com" }]
keywords = ["notes", "cli", "fastapi"]
classifiers = [
"Programming Language :: Python :: 3.12",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
]
[project.urls]
Homepage = "https://github.com/你/notekit"
Issues = "https://github.com/你/notekit/issues"
- 写 README.md。它会直接显示在 PyPI 页面上(
readme = "README.md"那行的作用)。我的示例项目 README 是空的——这是我的偷懒,真发布前必须写。 - 先发到 TestPyPI 试一次。这是一个和 PyPI 完全隔离的沙箱,专门给你练手:
# 1) 去 test.pypi.org 注册,创建一个 API token(不是密码)
# 2) 发布
uv publish --publish-url https://test.pypi.org/legacy/ --token pypi-你的token
# 3) 从 TestPyPI 装回来验证
uv pip install --index-url https://test.pypi.org/simple/ \
--extra-index-url https://pypi.org/simple/ notekit-你的后缀
--extra-index-url 是必需的:TestPyPI 上没有 fastapi、pydantic 这些真实依赖,得让它去真 PyPI 找。
确认没问题了再发正式版:
uv publish --token pypi-你的正式token
发布前的安全和纪律检查(这几条会真出事):
- PyPI 的版本号不可覆盖、不可重发。 发错了只能 yank(标记为不推荐)然后发
0.1.1。没有"删了重发"这个操作。 所以 TestPyPI 那一步别跳。 - 用 API token,不要用密码,而且 token 要限定到单个项目。token 就是密钥——只放环境变量
UV_PUBLISH_TOKEN,绝不写进代码、CI 配置文件或pyproject.toml。CI 里用平台的 secrets 功能。 - 发布前
unzip -l检查 wheel 里没有.env、没有密钥、没有内部数据。 一旦发上去,就算 yank 了,别人也已经下载/镜像/索引过了。PyPI 上泄露密钥等于公开广播。 - CI 里发布推荐用 Trusted Publishing(PyPI 的 OIDC 机制),这样连 token 都不用存——CI 直接用身份凭证换取一次性上传权限。
- 注意包名安全:别装名字看着像知名包但拼写略有不同的东西(
requsetsvsrequests)——这叫 typosquatting,是真实存在的供应链攻击。你自己发布时也可以顺手把常见拼错的名字占下来防御。
11.6 版本号怎么定:语义化版本
0.1.0 这三段叫 SemVer(语义化版本),和 Android 一样的规矩:
| 段 | 什么时候加 | 例子 |
|---|---|---|
| 主版本 MAJOR | 破坏兼容:删了函数、改了参数、改了返回格式 | 1.0.0 → 2.0.0 |
| 次版本 MINOR | 加功能且向后兼容 | 1.2.0 → 1.3.0 |
| 修订 PATCH | 只修 bug | 1.2.3 → 1.2.4 |
0.x.y 是特殊约定:"还没稳定,我随时可能破坏兼容"。所以别人依赖你的 0.x 版本时会写 ==0.1.* 而不是 >=0.1。
依赖声明的写法对应 SemVer:
| 写法 | 含义 |
|---|---|
pydantic>=2 |
2 及以上任何版本(赌 v2 系列不破坏兼容) |
pydantic>=2.13,<3 |
推荐写法:允许小版本升级,挡住大版本 |
pydantic==2.13.4 |
钉死(库里别这么写,见 11.2) |
pydantic~=2.13.4 |
等价于 >=2.13.4,<2.14——只允许 patch 升级 |
版本号写在哪? 我的项目里写了两处:pyproject.toml 的 version 和 __init__.py 的 __version__。两处不同步是经典 bug。 更好的做法是让它只有一处:
[project]
dynamic = ["version"] # 版本不写死在这
[tool.hatch.version] # 用 hatchling 后端时
path = "src/notekit/__init__.py"
或者反过来,代码里从包元数据读:
from importlib.metadata import version
__version__ = version("notekit")
11.7 uv.lock 和 uv export:让部署可复现
uv.lock 记录了整棵依赖树的精确版本 + 哈希。 它是 Gradle 的 gradle.lockfile,必须提交进版本库。
wc -c uv.lock
195509 uv.lock
19 万字节记录 40 个包——因为每个包都记了版本、来源 URL、所有平台的 wheel 哈希、以及它自己的依赖关系。
三条日常命令:
uv sync --frozen # 严格按锁文件装,锁文件和 pyproject 不一致就报错(CI/部署用这个)
uv lock --check # 只检查锁文件是不是最新的,不改任何东西
uv lock --upgrade # 主动升级所有依赖并更新锁文件
uv sync --frozen 的真实输出:
Checked 39 packages in 8ms
uv lock --check:
Resolved 40 packages in 3ms
--frozen 是部署的关键:它保证"绝不悄悄改版本"。没有它,uv sync 可能因为 pyproject.toml 有变动而重新解析出新版本——部署时最不想遇到的事就是"昨天好的今天挂了,因为某个依赖发了新版"。这就是 12 节 Dockerfile 里用 uv sync --frozen 的原因。
如果部署环境不装 uv 呢? 导出成传统的 requirements.txt:
uv export --no-dev --no-emit-project -o requirements.txt
wc -l requirements.txt
447 requirements.txt
447 行记录 29 个运行时包,因为每个包都带上了所有平台的哈希:
# This file was autogenerated by uv via the following command:
# uv export --no-dev --no-emit-project -o requirements.txt
annotated-doc==0.0.5 \
--hash=sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101 \
--hash=sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb
# via
# fastapi
# typer
annotated-types==0.8.0 \
--hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7 \
--hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0
# via pydantic
--hash= 是供应链安全的关键:pip 装的时候会校验下载文件的哈希,不匹配就拒绝安装。这能挡住"包被人在传输途中替换"或"PyPI 上的包被重新上传"。要强制校验,装的时候用 pip install --require-hashes -r requirements.txt。
# via 注释告诉你这个包是谁要求的——排查"这个包哪来的"时非常有用。
| 文件 | 提交进 git? | 干什么用 |
|---|---|---|
pyproject.toml |
✅ 必须 | 声明"我要什么"(范围) |
uv.lock |
✅ 必须 | 记录"实际装了什么"(精确) |
requirements.txt |
⚠️ 视情况 | 给不装 uv 的环境用,是导出产物,不要手改 |
.python-version |
✅ 建议 | 记录项目用哪个 Python 版本 |
.venv/ |
❌ 绝不 | 本地环境,几百 MB,且不跨平台 |
.env |
❌ 绝不 | 密钥 |
dist/ |
❌ 不 | 构建产物 |
十二、部署:让服务真的跑在别的机器上
Android 里"部署"就是传商店让用户下载。后端服务的部署是另一回事:你要负责它一直活着。这一节走三步——先把服务用生产方式跑起来,再装进 Docker 镜像,最后用 compose 管起来。
12.1 开发服务器 vs 生产服务器
前面一直用 notekit serve,它内部是 uvicorn.run(...)——单进程。生产环境要多进程:
uv run uvicorn notekit.web:api --host 127.0.0.1 --port 8125 --workers 4
真实输出:
INFO: Uvicorn running on http://127.0.0.1:8125 (Press CTRL+C to quit)
INFO: Started parent process [28336]
INFO: Started server process [28339]
INFO: Waiting for application startup.
INFO: Started server process [28340]
INFO: Started server process [28341]
INFO: Started server process [28338]
INFO: Waiting for application startup.
INFO: Waiting for application startup.
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Application startup complete.
INFO: Application startup complete.
INFO: Application startup complete.
一个父进程 + 4 个工作进程,四个 Application startup complete 各一条。它们共享同一个监听端口,操作系统负责把连接分给谁。
为什么要多进程?回到 8.3 节的 GIL。 一个 Python 进程里的多个线程不能同时执行 Python 字节码,所以单进程的 CPU 上限就是一个核。多进程绕开 GIL,4 个 worker 能吃 4 个核。
worker 数量怎么定?
| 你的服务主要在干什么 | 推荐 worker 数 |
|---|---|
| 等外部 IO(调大模型 API、查数据库)——Agent 服务的典型情况 | CPU 核数 就够,甚至更少;瓶颈在网络不在 CPU |
| CPU 密集(本地推理、大量 JSON 解析/编码) | CPU 核数,最多 核数 + 1 |
| 混合 | 从 核数 开始,压测后调整 |
别照搬"2 × 核数 + 1"那个 Gunicorn 老公式——那是给同步阻塞框架的。FastAPI 的 async def 接口在单个 worker 内就能并发处理成百上千个等待中的请求(6.6 节实测过:串行 2.47 秒的活并发只要 0.33 秒)。先把 async 写对,再考虑加 worker。
四个必须知道的部署细节:
--workers和--reload不能一起用。--reload是开发用的(改代码自动重启),生产环境绝不能开——它要监视文件系统,而且会让 worker 管理变得不可预测。- 多 worker 意味着进程间不共享内存。
notekit的NoteStore是每个请求新建的(读文件),所以没事;但如果你在模块级放了一个全局字典当缓存,4 个 worker 会有 4 份互不相同的缓存。要共享状态就得用 Redis 或数据库。 notekit的 JSON 文件存储在多 worker 下会丢数据——两个进程同时_save()会互相覆盖(读-改-写不是原子的)。这是示例项目的已知局限,我没有修。真实项目请用 SQLite(sqlite3标准库,WAL 模式下支持并发读+单写)或 Postgres。这是"教学代码"和"生产代码"最典型的一条分界线。- 真实生产还要在 uvicorn 前面放一层 Nginx/Caddy,负责 HTTPS、限流、静态文件和超时。uvicorn 直接对公网也能跑,但少了这些保护。
12.2 Docker:为什么要它
Android 的类比不太现成,但可以这样理解:Docker 镜像是一个"打包了操作系统 + Python + 你的依赖 + 你的代码"的完整快照。它解决的正是"在我机器上是好的"这个问题——因为镜像里连 Python 版本都是固定的。
三个词分清楚:
| 词 | 是什么 | 类比 |
|---|---|---|
| Dockerfile | 一份"怎么造镜像"的说明书 | build.gradle |
| 镜像 image | 造出来的只读快照 | .apk 文件 |
| 容器 container | 镜像跑起来的一个实例 | 手机上正在运行的 App |
一个镜像可以同时跑很多容器,就像一个 apk 可以装在很多台手机上。
12.3 多阶段 Dockerfile 逐行讲
Dockerfile:
# ---------- 第一阶段 builder:装依赖,装完就丢 ----------
# 用官方带 uv 的镜像,省掉自己装 uv 的步骤
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy
WORKDIR /app
# 关键技巧:先只拷依赖清单,再装依赖。
# 只要 pyproject.toml / uv.lock 没变,Docker 就复用这一层缓存,
# 改业务代码时不会重新装一遍包。
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project --no-dev
# 再拷源码,装项目本身
COPY src ./src
COPY README.md ./
RUN uv sync --frozen --no-dev
# ---------- 第二阶段 runtime:只带运行时需要的东西 ----------
# 用同系列的镜像做运行时,保证 .venv 里的二进制能对上
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
# 不用 root 跑服务 —— 容器安全第一条
RUN useradd --create-home --uid 10001 appuser
WORKDIR /app
COPY --from=builder --chown=appuser:appuser /app/.venv /app/.venv
COPY --from=builder --chown=appuser:appuser /app/src /app/src
ENV PATH="/app/.venv/bin:$PATH" \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
NOTEKIT_DATA=/data/notes.json
RUN mkdir -p /data && chown appuser:appuser /data
VOLUME ["/data"]
USER appuser
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD python -c "import httpx,sys; sys.exit(0 if httpx.get('http://127.0.0.1:8000/health').status_code==200 else 1)"
# host 必须是 0.0.0.0:容器内只听 127.0.0.1,外面永远连不上
CMD ["uvicorn", "notekit.web:api", "--host", "0.0.0.0", "--port", "8000"]
十条要点,每条都是一个真实教训:
1. 为什么分两个阶段(multi-stage)? 第一阶段可以随便装编译工具、留缓存、产生垃圾;第二阶段只把 .venv 和 src 拷过来,前面的中间产物全部丢弃,不进最终镜像。镜像小 = 拉取快 = 部署快 = 攻击面小。
2. COPY pyproject.toml uv.lock ./ 必须在 COPY src 之前。 这是整个 Dockerfile 最重要的一行顺序。Docker 的每条指令是一"层",只要某层的输入没变就复用缓存,一层变了后面全部重来。依赖清单变得少、源码变得多,所以先拷清单装依赖、后拷源码。顺序写反的话,你每改一个字符都要重装 40 个包。 下一节实测。
3. --no-install-project:第一次 sync 时只装依赖、不装你自己的项目(因为源码还没拷进来)。少了这个参数会报错。
4. --no-dev:不装 pytest/ruff/mypy。生产镜像里不该有测试工具——多余体积 + 多余攻击面。
5. UV_COMPILE_BYTECODE=1:装包时就把 .py 编译成 .pyc。代价是构建慢一点,收益是容器启动快(不用第一次运行时才编译)。容器可能频繁重启,这个交换很值。
6. UV_LINK_MODE=copy:uv 默认用硬链接省空间,但跨 Docker 层的硬链接会出问题,改成复制。不加会看到一堆警告。
7. useradd --uid 10001 appuser + USER appuser:这是容器安全第一条。 默认容器里的进程是 root,一旦被攻破,攻击者在容器里就是 root,更容易逃逸到宿主机。指定固定 uid(10001) 而不是让系统随便分,是为了让挂载卷的文件权限可预测。
8. PYTHONUNBUFFERED=1:不加这个,你的日志会看不见。 Python 的 stdout 在管道里默认是块缓冲的(不是行缓冲),日志会攒在缓冲区里不输出,docker logs 就看不到——容器崩溃时最后那几行关键日志正好丢了。这是排查容器问题时最坑的一条。
9. HEALTHCHECK 是给编排系统看的。它定期跑那条命令,exit 0 算健康。Kubernetes/compose 靠它决定"这个容器是不是该重启 / 该不该往它转发流量"。注意 --start-period=5s:启动期内失败不算数,避免"还没启动完就被判死"。
10. --host 0.0.0.0 在容器里是必须的。 容器有自己的网络命名空间,127.0.0.1 指的是容器内部的回环口,宿主机的请求进不来。这是新手部署 Docker 时最常见的"端口映射了但连不上"的原因。 注意这和 10.9 节 serve 默认 127.0.0.1 不矛盾——那是本机开发,这是容器内部,容器的对外暴露由 ports: 那一层控制。
.dockerignore——和 .gitignore 一样重要:
.venv
.git
.gitignore
__pycache__
*.pyc
.pytest_cache
.mypy_cache
.ruff_cache
dist
data
notes.json
tests
.env
requirements.txt
三个理由,按重要性排:
.env必须排除——否则你的密钥会被烤进镜像。镜像会被推到仓库、被别人拉取,镜像层里的文件即使后续层删掉了也还在历史层里能翻出来。这和 git 提交历史里的密钥是同一类问题。.venv必须排除——它是你 macOS 上的环境,里面的二进制在 Linux 容器里跑不了,还会覆盖掉容器里正确的.venv。而且几百 MB,拖慢构建。.git、缓存目录排除是为了减小构建上下文(Docker 会把整个目录打包发给 daemon)。
12.4 ✅ Checkpoint 15:构建镜像并验证缓存机制
docker build -t notekit:0.1.0 .
docker images notekit
真实输出:
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
notekit:0.1.0 5e185f6836d7 355MB 82.9MB U
355MB 磁盘占用 / 82.9MB 压缩后内容。其中你的代码只有 11KB(11.2 节量过),剩下全是 Debian 基础系统 + Python + 29 个依赖包。想更小可以换 alpine 基础镜像,但 alpine 用 musl libc,很多带 C 扩展的包(numpy、pandas)没有现成 wheel,要现场编译——通常不值得。
现在验证 12.3 第 2 点说的层缓存。我在 cli.py 末尾加了一行纯注释(只改代码,不改任何依赖):
# 一行注释,只改代码不改依赖
然后重新构建:
docker build -t notekit:0.1.1 .
关键的两行输出:
#8 [builder 4/7] RUN uv sync --frozen --no-install-project --no-dev
#8 CACHED
#11 [builder 7/7] RUN uv sync --frozen --no-dev
#11 ...(重新执行了)
装 39 个依赖那一层是 CACHED,只有装项目本身那一层重跑了。 这就是把 COPY pyproject.toml uv.lock 放在 COPY src 前面换来的收益——改代码的构建从几十秒变成几秒。
对照 Android:这和 Gradle 的增量编译 + 构建缓存是同一个思路,区别是 Docker 的缓存粒度是"整层",而且一层失效后面全失效。所以 Dockerfile 里指令的顺序原则是:越少变的放越前面。
12.5 compose:把运行参数写进文件
docker run 的参数一多就记不住了。compose 把它们写成文件:
compose.yaml:
services:
api:
build: .
image: notekit:0.1.0
ports:
- "8202:8000"
volumes:
- notekit-data:/data
environment:
# 生产环境的 key 从宿主机环境变量透传,绝不写进 compose 文件
LLM_API_KEY: ${LLM_API_KEY:-}
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import httpx,sys; sys.exit(0 if httpx.get('http://127.0.0.1:8000/health').status_code==200 else 1)"]
interval: 30s
timeout: 3s
retries: 3
start_period: 5s
volumes:
notekit-data:
逐项解释:
| 配置 | 含义 |
|---|---|
build: . |
用当前目录的 Dockerfile 构建 |
ports: "8202:8000" |
宿主机 8202 → 容器 8000。左边是外面,右边是里面 |
volumes: notekit-data:/data |
把一个命名卷挂到容器的 /data——数据活过容器删除 |
environment: LLM_API_KEY: ${LLM_API_KEY:-} |
从宿主机环境变量透传,:- 是"没设置就用空字符串" |
restart: unless-stopped |
容器挂了自动重启,除非你手动停 |
healthcheck |
和 Dockerfile 里那条等价(compose 里写一遍可以覆盖镜像里的) |
${LLM_API_KEY:-} 这一行是密钥纪律在容器环境的落地:compose 文件是要提交进 git 的,所以里面绝不能有真实值,只能写"去宿主机环境变量拿"。宿主机上的值来自 .env(compose 会自动读同目录的 .env)或 CI 的 secrets。
:- 的作用别小看:不写它的话,宿主机没设这个变量时 compose 会报警告或传字面量。:- 明确表达"这个是可选的"。
跑起来:
docker compose up -d --build
docker compose ps
真实输出:
NAME IMAGE STATUS PORTS
notekit-api-1 notekit:0.1.0 Up 34 minutes (healthy) 0.0.0.0:8202->8000/tcp, [::]:8202->8000/tcp
(healthy) 是 HEALTHCHECK 报的——说明容器不只是"在跑",而是"接口真的能响应"。只看 Up 是不够的:进程活着但接口 500 的情况很常见。
常用命令:
docker compose logs -f api # 跟踪日志(PYTHONUNBUFFERED=1 才看得见)
docker compose exec api sh # 进容器里看看
docker compose restart api # 重启
docker compose down # 停止并删除容器(卷保留)
docker compose down -v # 连卷一起删(数据没了!)
docker compose down -v 会删数据卷,这个操作不可逆。 生产环境上执行前想清楚。
12.6 ✅ Checkpoint 16:容器里的三件事
第一件:确认不是 root 在跑。
docker exec notekit-api-1 id
uid=10001(appuser) gid=10001(appuser) groups=10001(appuser)
不是 uid=0(root),安全要求达标。
第二件:确认容器里的 Python 版本。
docker exec notekit-api-1 python -V
Python 3.12.12
注意和我本机的 3.12.13 差了一个补丁号——因为镜像里的 Python 来自镜像作者构建时的版本。这正是 Docker 的价值:容器里的版本由镜像固定,不受我本机影响;反过来也说明"镜像固定"和"本机一致"是两件事,本机测过 ≠ 容器里一样。
第三件:确认数据真的持久化了。 这是最容易出错的一环,要分两级验证:
# 写一条数据
curl -s -X POST http://127.0.0.1:8202/notes -H 'Content-Type: application/json' \
-d '{"title":"容器里写的","tags":["docker"]}'
{"id":2,"title":"容器里写的","body":"","tags":["docker"],"done":false,"created_at":"2026-08-16T10:29:24.026027Z"}
# 一级:重启容器(进程重来,容器还是那个)
docker compose restart api && sleep 5
curl -s http://127.0.0.1:8202/notes
# 二级:删掉容器再重建(这才是真考验)
docker compose down
docker compose up -d && sleep 6
curl -s http://127.0.0.1:8202/notes
down 的真实输出(容器和网络都被删掉了):
Container notekit-api-1 Stopped
Container notekit-api-1 Removing
Container notekit-api-1 Removed
Network notekit_default Removing
Network notekit_default Removed
up -d 之后再查,数据还在:
[{"id":1,"title":"容器里的笔记","body":"","tags":["docker"],"done":false,"created_at":"2026-08-16T09:52:13.725737Z"},{"id":2,"title":"容器里写的","body":"","tags":["docker"],"done":false,"created_at":"2026-08-16T10:29:24.026027Z"}]
NAME IMAGE SERVICE CREATED STATUS PORTS
notekit-api-1 notekit:0.1.0 api 6 seconds ago Up 6 seconds (healthy) 0.0.0.0:8202->8000/tcp, [::]:8202->8000/tcp
容器是新建的(CREATED 6 seconds ago),但两条笔记都还在——说明数据在卷里而不在容器的可写层里。只测 restart 是不够的:restart 不删容器,容器自己的文件系统还在,即使你完全没配卷也能"看起来正常"。必须测 down + up。
看一眼卷:
docker volume ls | grep note
local notekit-data
local notekit_notekit-data
这里有个真实的坑,我撞了。 两个卷名,因为: -
notekit-data是我早先用docker run -v notekit-data:/data手动建的 -notekit_notekit-data是 compose 建的——compose 会自动给卷名加上"项目名_"前缀(项目名默认是目录名notekit)后果:我以为在用同一个卷,实际是两个,数据"莫名消失"。排查了半天。
解决办法:在 compose 里显式声明外部卷:
yaml volumes: notekit-data: name: notekit-data # 不加项目名前缀或者干脆统一都用 compose 管,别混用docker run。看到"数据不见了",第一件事是docker volume ls数一下有几个卷。
12.7 部署形态怎么选
| 方式 | 适合 | 代价 |
|---|---|---|
| 裸机 + systemd | 单机小服务、内部工具 | 要自己管 Python 版本、依赖、日志轮转 |
| Docker + compose | 单机到几台机器,绝大多数场景的正确答案 | 要学 Docker |
| Kubernetes | 几十个服务、要自动扩缩容 | 复杂度高一个数量级,小团队通常不值 |
| Serverless(云函数) | 流量稀疏、突发 | 冷启动慢(Python 尤其)、有执行时长上限 |
| PaaS(Railway/Fly.io/云厂商容器服务) | 想少操心,接受平台绑定 | 贵一些,可控性低 |
给 Agent 服务的具体建议:Agent 的特点是每个请求要等外部大模型几秒到几十秒。这意味着:
- 超时设置要往大调——网关、反向代理、客户端三处的超时都要检查。默认的 30 秒经常不够。
- 别用 Serverless 跑长 Agent 循环——多步 Agent 很容易超过执行时长上限。
- 必须用 async(6.6/6.7 节)——同步阻塞会让一个 worker 只能同时处理一个请求。
- 要有
max_steps保险丝(前面章节反复强调过)——Agent 循环不设上限,一个 bug 能把你的 API 额度烧光。 - 密钥只从环境变量注入,容器镜像里、compose 文件里、日志里都不能有。
十三、调试与踩坑:Python 特有的那些坑
Android 里编译器帮你挡掉的错误,Python 里会在运行时才炸。所以你需要两样东西:会读报错,和知道哪些坑是 Python 特有的。
13.1 读 Traceback:从下往上看
import traceback
def layer3(data):
return data["missing"] # 这里才是真正的错
def layer2(data):
return layer3(data)
def layer1(data):
return layer2(data)
try:
layer1({"a": 1})
except KeyError:
print(traceback.format_exc())
真实输出:
Traceback (most recent call last):
File "/private/tmp/verify/py/h_debug.py", line 22, in <module>
layer1({"a": 1})
File "/private/tmp/verify/py/h_debug.py", line 18, in layer1
return layer2(data)
^^^^^^^^^^^^
File "/private/tmp/verify/py/h_debug.py", line 14, in layer2
return layer3(data)
^^^^^^^^^^^^
File "/private/tmp/verify/py/h_debug.py", line 10, in layer3
return data["missing"] # 这里才是真正的错
~~~~^^^^^^^^^^^
KeyError: 'missing'
读法(和 Java 栈轨迹相反!):
| 位置 | 内容 |
|---|---|
| 最后一行 | 错误类型 + 消息 —— 先看这个 |
最靠下的 File 行 |
出错的那一行,也就是最深的调用 |
往上的 File 行 |
调用链,从入口一路调下来 |
| 第一行 | 最外层的调用点 |
Java 的 stack trace 是最上面那行最深,Python 是最下面那行最深。 这个方向差异会让你一开始很别扭,记住"Python 从下往上看"就行。
^^^^ 和 ~~~~ 是 Python 3.11+ 送的礼物:它精确指出是表达式的哪一部分出错。上面 ~~~~^^^^^^^^^^^ 中,~~~~ 标出 data、^^^^ 标出 ["missing"]——告诉你是下标操作错了,不是 data 本身有问题。一行里有多个下标/调用时,这个标记非常省时间。
13.2 七种最常见的报错,对号入座
cases = [
("NameError", lambda: undefined_variable),
("TypeError", lambda: "1" + 1),
("AttributeError", lambda: None.strip()),
("KeyError", lambda: {"a": 1}["b"]),
("IndexError", lambda: [1, 2][5]),
("ValueError", lambda: int("abc")),
("ZeroDivisionError", lambda: 1 / 0),
]
for name, fn in cases:
try:
fn()
except Exception as e:
print(f" {name:<20} -> {type(e).__name__}: {e}")
真实输出:
NameError -> NameError: name 'undefined_variable' is not defined
TypeError -> TypeError: can only concatenate str (not "int") to str
AttributeError -> AttributeError: 'NoneType' object has no attribute 'strip'
KeyError -> KeyError: 'b'
IndexError -> IndexError: list index out of range
ValueError -> ValueError: invalid literal for int() with base 10: 'abc'
ZeroDivisionError -> ZeroDivisionError: division by zero
| 报错 | 意思 | 最常见的真实原因 | Kotlin 里对应 |
|---|---|---|---|
NameError |
用了不存在的名字 | 打错字、忘了 import、变量定义在了 if 分支里 | 编译期就报错 |
TypeError |
类型不对 | 把 str 和 int 相加、参数个数不对 |
编译期就报错 |
AttributeError: 'NoneType' object has no attribute ... |
对 None 调方法 | 函数返回了 None 你没检查 | NullPointerException |
KeyError |
字典里没这个键 | 解析大模型返回的 JSON 时字段名对不上 | 无(Kotlin map 返回 null) |
IndexError |
下标越界 | 列表是空的 | IndexOutOfBoundsException |
ValueError |
类型对但值不合法 | int("abc")、用户输入没校验 |
NumberFormatException |
ModuleNotFoundError |
找不到模块 | 装到了别的环境(5.4 节) | 依赖没声明 |
AttributeError: 'NoneType' object has no attribute 是你会见得最多的一个,它就是 Python 的空指针异常。根因几乎总是"某个函数返回了 None 而你直接用了"——比如 store.get(id) 找不到时返回 None,你没判断就 .title。这也是 mypy 最能帮到你的场景:Note | None 类型的值,mypy 会强制你先判空(7 节)。
排查 AttributeError 的两个高效手段:
print(type(x)) # 它到底是什么类型
print(dir(x)) # 它到底有哪些方法/属性
13.3 浅拷贝 vs 深拷贝
import copy
original = {"name": "小林", "tags": ["a", "b"]}
shallow = original.copy()
deep = copy.deepcopy(original)
shallow["tags"].append("c")
print(" original:", original)
print(" shallow :", shallow)
print(" deep :", deep)
真实输出:
改了 shallow['tags'] 之后:
original: {'name': '小林', 'tags': ['a', 'b', 'c']}
shallow : {'name': '小林', 'tags': ['a', 'b', 'c']}
deep : {'name': '小林', 'tags': ['a', 'b']}
.copy() 只复制第一层:新字典是新的,但里面那个列表还是同一个对象,所以改了 shallow 的列表,original 也变了。这就是 2.2 节"变量是标签不是盒子"的直接后果。
| 方式 | 复制深度 | 什么时候用 |
|---|---|---|
d2 = d1 |
完全不复制,只是多一个标签 | 想要同一个对象 |
d1.copy() / dict(d1) / list(l1) / l1[:] |
一层 | 里面全是不可变值(数字、字符串)时够用 |
copy.deepcopy(d1) |
递归全部 | 有嵌套的可变对象时必须 |
note.model_copy()(pydantic) |
一层(deep=True 才递归) |
pydantic 模型 |
真实场景:给大模型准备 messages 列表时,你想在原始对话上加一条系统提示但不污染原列表——messages.copy() 是不够的,因为每条消息本身是字典。要么 copy.deepcopy,要么 [*messages, new_msg](新建列表,元素共享但你不改它们)。
13.4 [[0]*3]*3:Python 最著名的坑
grid_bad = [[0] * 3] * 3
grid_bad[0][0] = 9
print(grid_bad)
grid_ok = [[0] * 3 for _ in range(3)]
grid_ok[0][0] = 9
print(grid_ok)
真实输出:
[[0]*3]*3 改一个 -> [[9, 0, 0], [9, 0, 0], [9, 0, 0]] <- 三行是同一个列表!
推导式生成 改一个 -> [[9, 0, 0], [0, 0, 0], [0, 0, 0]]
列表 * 3 复制的是"引用",不是"内容"。 三行是同一个列表对象的三个标签,改一个全变。
规则:要建二维结构,永远用推导式 [[0]*n for _ in range(m)]。 注意内层的 [0]*3 是安全的——因为 0 是不可变的整数,共享也无所谓。只有元素是可变对象(列表、字典、自定义类)时才会踩这个坑。
这和 2.10 节的可变默认参数、3.x 节的 field(default_factory=list) 是同一个根源:Python 里"复制一个可变对象"必须显式做,不会帮你自动做。
13.5 遍历时修改列表
nums = [1, 2, 4, 6, 7]
for n in nums[:]: # 注意 [:] —— 遍历副本
if n % 2 == 0:
nums.remove(n)
print(" 遍历副本再删,正确:", nums)
nums2 = [1, 2, 4, 6, 7]
for n in nums2: # 直接遍历原列表
if n % 2 == 0:
nums2.remove(n)
print(" 直接遍历边删,出错:", nums2)
真实输出:
遍历副本再删,正确: [1, 7]
直接遍历边删,出错: [1, 4, 7] <- 4 被跳过了
过程拆解(遍历原列表时):
下标0 看到 1 列表=[1, 2, 4, 6, 7]
下标1 看到 2 列表=[1, 2, 4, 6, 7]
下标2 看到 6 列表=[1, 4, 6, 7]
最推荐的写法是根本不删,而是生成新列表:
[1, 7]
看那个过程拆解就懂了:删掉下标 1 的 2 之后,4 挪到了下标 1,但循环的指针已经走到下标 2 了——4 被跳过,永远不会被检查。
注意 Python 和 Java 的区别:Java 的 ArrayList 在遍历中修改会抛 ConcurrentModificationException——它至少会报错。Python 默默给你一个错的结果,这更危险。(顺带说,Python 的 dict 在遍历中改大小会抛 RuntimeError: dictionary changed size during iteration,比 list 好一点。)
三种正确写法,按推荐度排:
nums = [n for n in nums if n % 2 != 0] # 1. 最好:生成新列表,意图最清楚
for n in nums[:]: ... # 2. 遍历副本
for i in range(len(nums) - 1, -1, -1): # 3. 倒着遍历(删了不影响没走过的下标)
第一种是 Python 的惯用法——"过滤"这个意图直接写成推导式,比"边遍历边删"清晰得多,也不会有性能问题(list.remove 是 O(n),循环里删是 O(n²))。
13.6 is 和 ==:三个反直觉的实测
a = 1000
b = 1000
print("a=1000; b=1000 -> a is b ?", a is b)
c, d = int("1000"), int("1000")
print("int('1000') is int('1000') ?", c is d, "| c == d ->", c == d)
print("100 is int('100') ?", 100 is int("100"))
真实输出:
写在同一个文件里的 a=1000; b=1000 -> a is b ? True
(CPython 会把同一个代码块里的相同常量合并成一个对象,所以是 True)
运行时算出来的 int('1000') is int('1000') ? False
但值相等吗? c == d -> True
小整数 100 is int('100') ? True (-5~256 被解释器预先缓存了)
同样的『1000』,三种写法给出三种 is 结果 —— 全是实现细节。
同一个数值 1000,三种写法给出两种 is 结果,而 100 又不一样。 原因全是 CPython 的实现细节:
- 同一个代码块里的字面量常量会被合并(编译器优化)
- 运行时计算出的整数是新对象
- -5 到 256 的小整数被解释器预先缓存,永远是同一个对象
结论(这条是硬规则,别记那些细节):
判断值相等永远用
==。is只用来判断"是不是同一个对象",实践中基本只写x is None/x is True/x is False。
为什么 is None 反而推荐用 is? 因为 None 全局只有一个对象,is None 又快又准;而 == None 可能被自定义的 __eq__ 改变行为。同理,判断布尔值用 x is True——1 == True 是 True(2.4 节),但 1 is True 是 False。这就是 store.py 里写 n.done is done 而不是 n.done == done 的原因。
13.7 breakpoint():内置调试器
def buggy(items):
total = 0
for it in items:
breakpoint() # 运行到这里会停下来,进入 pdb
total += it["price"]
return total
进入 pdb 后的常用命令:
| 命令 | 作用 |
|---|---|
n(next) |
执行下一行,不进入函数 |
s(step) |
执行下一行,进入函数内部 |
c(continue) |
继续跑,直到下一个断点 |
p 变量名 |
打印变量 |
pp 变量名 |
漂亮地打印(嵌套结构会换行缩进) |
l(list) |
看当前位置附近的代码 |
w(where) |
看调用栈 |
q(quit) |
退出 |
breakpoint() 是 3.7+ 的内置函数,不需要 import。IDE 里点行号打断点效果一样,原理就是这个。在容器里、在 CI 里、在 SSH 上没有 IDE 的时候,breakpoint() 是唯一的选择。
还有一个更轻量的技巧——f-string 的 = 语法(2.3 节):
print(f"{items=} {total=}")
比 print("items:", items, "total:", total) 少打一半字,而且不会写错变量名对应关系。80% 的调试用这个就够了。
13.8 全章踩坑总表
按"你多久会撞一次"排序:
| # | 坑 | 现象 | 正确做法 | 出处 |
|---|---|---|---|---|
| 1 | 缩进不一致 | IndentationError / TabError |
统一 4 空格,编辑器设置"Tab 转空格" | §2.1 |
| 2 | 对 None 调方法 |
AttributeError: 'NoneType' ... |
函数返回 X \| None 时必须判空;用 mypy 强制 |
§13.2 |
| 3 | 可变默认参数 | 第二次调用带上了上次的数据 | def f(x=None): x = x or [];dataclass/pydantic 用 default_factory |
§2.10 |
| 4 | ModuleNotFoundError |
明明装了却说找不到 | 确认在同一个环境:uv run python -c "import sys; print(sys.executable)" |
§5.3 |
| 5 | 忘了 encoding="utf-8" |
Windows 上中文乱码/报错 | 每次读写文本文件都显式写 | §4.4 |
| 6 | model_dump() 忘了 mode="json" |
TypeError: Object of type datetime is not JSON serializable |
要转 JSON 就用 mode="json" |
§10.4 |
| 7 | == 和 is 混用 |
时对时错,难复现 | 值比较用 ==;只用 is None |
§13.6 |
| 8 | 浅拷贝以为是深拷贝 | 改副本原件也变了 | 嵌套结构用 copy.deepcopy |
§13.3 |
| 9 | [[0]*3]*3 |
改一行三行全变 | [[0]*3 for _ in range(3)] |
§13.4 |
| 10 | 遍历时删元素 | 静默跳过元素,不报错 | 用推导式生成新列表 | §13.5 |
| 11 | except Exception 太宽 |
真 bug(打错字、类型错)被当成"预期失败"吞掉 | 捕获具体异常;try 块越小越好(Ctrl+C 倒是吞不掉——实测 KeyboardInterrupt 不是 Exception 的子类) |
§4.3 |
| 12 | async def 里用 time.sleep |
并发完全失效,实测 0.61s vs 0.30s | 用 await asyncio.sleep;阻塞库用 asyncio.to_thread |
§6.7 |
| 13 | CPU 密集用多线程 | 一点没快,实测 0.70 → 0.67 | GIL 决定的,用 ProcessPoolExecutor |
§8.3 |
| 14 | naive/aware datetime 混用 | can't compare offset-naive and offset-aware datetimes |
内部永远用 datetime.now(UTC) |
§8.1 |
| 15 | 类型注解以为会检查 | 传错类型照样跑,输出 12 |
必须单独跑 mypy |
§7 |
| 16 | 相对导入 + 直接运行文件 | ImportError: attempted relative import with no known parent package |
用 python -m pkg.module |
§5.3 |
| 17 | 循环导入 | ImportError: cannot import name ... (most likely due to a circular import) |
保持单向依赖;必要时函数内 import | §5.3 |
| 18 | 用内置名当变量名 | TypeError: 'function' object is not subscriptable 等诡异错误 |
别用 list/dict/id/type/str |
§10.4 |
| 19 | model_copy 不校验 |
能造出违反约束的对象 | 改完补一次 model_validate |
§10.4 |
| 20 | PATCH 用了 model_dump() |
只想改一个字段,结果其他字段被清空 | model_dump(exclude_unset=True) |
§10.4 |
| 21 | 容器里监听 127.0.0.1 |
端口映射了但连不上 | 容器内用 --host 0.0.0.0 |
§12.3 |
| 22 | 容器日志看不见 | docker logs 是空的 |
PYTHONUNBUFFERED=1 |
§12.3 |
| 23 | Dockerfile 先拷源码后装依赖 | 改一个字符重装 40 个包 | 先 COPY pyproject.toml uv.lock |
§12.3/12.4 |
| 24 | compose 卷名带项目前缀 | 数据"莫名消失" | docker volume ls 数一下;用 name: 固定 |
§12.6 |
| 25 | .env 进了 git 或镜像 |
密钥泄露 | .gitignore + .dockerignore 都要写 |
§10.5/12.3 |
| 26 | uv venv 没有 pip |
No module named pip |
用 uv pip install,或 uv venv --seed |
§11.3 |
| 27 | 多 worker 共享内存缓存 | 4 个 worker 4 份缓存,行为不一致 | 用 Redis/数据库;或干脆无状态 | §12.1 |
| 28 | JSON 文件存储 + 多进程 | 并发写互相覆盖,丢数据 | 用 SQLite/Postgres | §12.1 |
13.9 专家提示:从"能写"到"写得像样"
这些不是语法,是工程习惯。Android 开发里你已经有一套,这里是 Python 的对应版本。
1. 把三道门做成一条命令,并接进 git hook / CI。
uv run ruff check . && uv run mypy src && uv run pytest -q
新项目第一天就把 mypy 打开,别等代码写了两万行再补。 中途引入 mypy 的痛苦程度和中途给一个 Java 项目引入空安全检查差不多。用 pre-commit 工具可以让它在 git commit 时自动跑。
2. 类型注解写在"边界"上,内部可以松。
公开函数的参数和返回值一定要写(别人靠它理解你的代码,mypy 靠它检查);函数内部的临时变量通常不用写(mypy 能推断)。投入产出比最高的三处:公开 API、返回 X | None 的函数、数据模型。
3. 优先用标准库,其次用"事实标准"库。
Python 的"电池"(标准库)非常全:pathlib/json/datetime/itertools/collections/logging/sqlite3/asyncio。先问"标准库有没有"。要装第三方时看三个指标:最近有没有更新、GitHub star/issue 响应速度、有没有类型标注(py.typed)。
4. 日志用 logging,不用 print。
print 没有级别、没有时间戳、没法关掉、进不了日志文件。服务端代码里出现 print 就是技术债。 而且记住 4.7 节的两点:用 log.info("耗时 %d ms", ms) 而不是 f-string(懒惰求值,级别不够时不做格式化),异常用 log.exception() 自动带上 traceback。
5. 别在模块顶层做有副作用的事。
模块顶层的代码在 import 时就会执行(5.1 节)。在顶层连数据库、读远程配置、启动线程,会让导入变慢、让测试难写、让循环导入更容易出现。 把它们放进函数里,或用 FastAPI 的 lifespan 事件。config.py 里那句 load_dotenv() 是个例外——它只读本地文件、幂等、且必须早于一切配置读取。
6. 拥抱不可变。
能用 tuple 就不用 list,能返回新对象就不改原对象(model_copy 而不是原地改)。Kotlin 里你已经习惯 val 优先,Python 没有 val,但设计上可以做到同样的效果——这能消掉一大半"改了副本原件也变了"类的 bug(13.3/13.4)。
7. 虚拟环境一个项目一个,永远不要 pip install 到系统 Python。
macOS/Linux 的系统 Python 是操作系统自己要用的,装乱了会修不回来(1 节量过:系统 Python 是 3.9.6)。永远 uv run 或激活项目的 .venv。
8. 写代码时就想"这段怎么测"。
notekit 的分层不是为了好看:store 不知道谁在用它,所以能直接测;get_store 是个依赖,所以能替换。如果一段逻辑难测,通常说明它耦合太紧——这是设计信号,不是测试的问题。
9. 读官方文档,别只读教程。
Python 的官方文档质量很高,尤其是标准库部分。pydantic、FastAPI、typer 的文档都是同一水平。遇到问题优先查官方文档 + 版本号——因为 pydantic v1→v2、typer、fastapi 都经历过 API 变化,网上的老答案会把你带偏。
10. 定期 uv lock --upgrade 并跑测试。
依赖长期不升会积累技术债,一次升级几十个包会痛不欲生。每两周升一次、跑一次测试,比半年后一次性升要轻松得多。
十四、面试视角
以下问题在 Python 后端 / AI 工程岗面试里出现频率很高。每一条都能在本章找到实测依据。
1. Python 的 GIL 是什么?它意味着什么?
GIL(全局解释器锁)是 CPython 解释器的一个互斥锁,保证同一时刻只有一个线程执行 Python 字节码。注意它是"CPython 的实现细节",不是 Python 语言规范——Jython、PyPy 的情况不同,CPython 3.13 也有实验性的 free-threaded 构建。
后果(§8.3 实测):CPU 密集任务多线程完全不会变快(单线程 0.70 秒 → 4 线程 0.67 秒),多进程才有效(0.22 秒);IO 密集任务多线程有效(1.22 秒 → 0.30 秒),因为等 IO 时会释放 GIL。
加分回答:所以写 Agent 服务时你几乎永远不需要 multiprocessing——瓶颈在等大模型返回,那是 IO,用 asyncio 就够,而且比线程更省资源。
2. async/await 和多线程的区别?什么时候 async 不管用?
async 是单线程内的协作式并发:遇到 await 就主动让出控制权,让事件循环去跑别的任务。多线程是操作系统抢占式调度。async 没有线程切换开销,能轻松开几万个并发任务。
async 失效的条件(§6.7 实测):async def 里调用了阻塞的代码。把 await asyncio.sleep(0.3) 换成 time.sleep(0.3),2 个任务的耗时从 0.30 秒变成 0.61 秒——完全串行,async 白写了。原因是 time.sleep 不会让出事件循环。
解决办法:换成 async 版本的库(requests → httpx.AsyncClient),或用 asyncio.to_thread(阻塞函数, 参数) 把它扔到线程池。
3. 可变默认参数为什么是坑?
def f(items=[]) 的默认值列表在函数定义时只创建一次,之后所有调用共享它。所以第二次调用会看到第一次留下的数据。
正确写法:def f(items=None): items = [] if items is None else items。dataclass 用 field(default_factory=list),pydantic 用 Field(default_factory=list)。
加分:ruff 的 B006/B008 规则专门抓这个;但 Typer 的 API 设计要求在默认值里调 Option(),所以要用 per-file-ignores 精确放行(§10.7)——这体现你懂"为什么放行"而不是"关掉烦人的警告"。
4. 类型提示会在运行时检查吗?
不会。 §7 实测:add("1", "2") 有类型注解 (int, int) -> int,Python 照样输出 12(字符串拼接);同一份代码 mypy 报 2 个 arg-type 错误。
注解只是元数据,存在 __annotations__ 里,运行时可以读(pydantic 和 FastAPI 就靠读它工作)。要真正检查必须单独跑 mypy 或 pyright。
5. == 和 is 的区别?为什么 x is None 反而推荐?
== 比较值(可被 __eq__ 重载),is 比较是否同一个对象(比较内存地址,不可重载)。
§13.6 实测:同一个文件里 a=1000; b=1000 → a is b 是 True(编译器常量合并);int('1000') is int('1000') 是 False;100 is int('100') 是 True(小整数缓存 -5~256)。这些全是 CPython 实现细节,不能依赖。
is None 推荐是因为 None 全局单例,又快又不会被 __eq__ 骗。判断布尔也用 is:1 == True 为真,但 1 is True 为假。
6. 深拷贝和浅拷贝?
.copy() 只复制第一层,嵌套的可变对象仍然共享(§13.3 实测:改 shallow['tags'],original 也变了)。copy.deepcopy() 递归复制。
衍生问题:[[0]*3]*3 为什么改一个变三个?因为 * 复制引用,三行是同一个列表对象(§13.4)。
7. 装饰器是什么?为什么要 functools.wraps?
装饰器是一个"接收函数、返回函数"的函数。@timed def f(): ... 完全等价于 f = timed(f)。
functools.wraps 把原函数的 __name__/__doc__/__annotations__ 复制到包装函数上。不加的话,被装饰函数的名字会变成 wrapper,日志、调试、以及依赖签名的框架(FastAPI!)全都会出问题。
带参数的装饰器需要三层嵌套:retry(times=3) → 外层收参数、中层收函数、内层是包装(§6.3)。
8. 生成器和列表的区别?
生成器惰性求值,一次只算一个、不存全部结果。§6.5 实测:装 10 万个数的列表占 800984 字节,等价生成器占 200 字节;而且从输出顺序能看出 yield 之后函数真的暂停了。
用在哪:读大文件(一行一行)、流式处理大模型的输出(token 一个个来)、无限序列。代价:只能遍历一次,没有 len(),不能下标访问。
9. pyproject.toml、uv.lock、requirements.txt 的关系?哪些要提交?
pyproject.toml:声明"我要什么"(范围,如pydantic>=2),必须提交uv.lock:记录"实际装了什么"(精确版本 + 哈希,19 万字节记 40 个包),必须提交requirements.txt:uv export的导出产物(447 行,带--hash),给不装 uv 的环境用,不要手改
关键原则:库声明宽松范围(避免版本冲突),应用用锁文件钉死(保证可复现)。所以 wheel 的 METADATA 里是 >=,而部署时用 uv sync --frozen。
10. Docker 多阶段构建解决什么问题?为什么 COPY 顺序重要?
多阶段让构建工具和中间产物留在第一阶段,最终镜像只带 .venv 和源码——更小、更安全。
COPY 顺序:Docker 每条指令一层,一层失效则后面全部失效。所以要先拷变化少的(pyproject.toml/uv.lock)装依赖,后拷变化多的源码。§12.4 实测:只改了 cli.py 一行注释,装 39 个依赖那层显示 CACHED,只有装项目本身那层重跑。
加分三点:USER appuser(不用 root)、PYTHONUNBUFFERED=1(否则日志看不见)、容器里必须 --host 0.0.0.0(否则端口映射了也连不上)。
11. PATCH 接口怎么区分"传了 null"和"没传"?
pydantic 的 model_dump(exclude_unset=True)——只返回调用方真正传了的字段。用普通 model_dump() 会把没传的字段当 None 一起更新,把用户的标题清空(§10.4)。
这是"要区分 0/None/未设置 就必须用 is None 而不是真值判断"这条原则在 API 层的具体形态。
12. 怎么测一个 FastAPI 应用?
TestClient(api) 在进程内直接调用接口函数,不开端口不走网络,所以 8 个 HTTP 测试几十毫秒跑完。
配合 api.dependency_overrides[get_store] = lambda: store 把真实存储换成临时目录里的(§10.11)——业务代码一行不改。fixture 里 yield 之后必须 clear(),否则污染后续测试。
这就是依赖注入的真正价值:不是为了"解耦"这个抽象说法,而是为了让测试能替换掉外部依赖。
13. 你怎么保证密钥不泄露?
四层,每层都实操过:
1. 密钥只放 .env 或环境变量,代码里一个字符都不留
2. .env 写进 .gitignore(进了提交历史就等于已泄露,删了也在历史里)
3. .env 写进 .dockerignore(否则被烤进镜像层,删了也能从历史层翻出来)
4. compose 里只写 ${LLM_API_KEY:-} 透传,绝不写真实值
加分:打日志只打长度和前缀;前端代码里永远不能出现 LLM 密钥(浏览器里所有人可见,必须后端代理);一旦泄露立刻去平台吊销重发;PyPI 发布前 unzip -l 检查 wheel 里没有 .env。
14. uvicorn 的 worker 数怎么定?
先问服务在干什么。等外部 IO 为主(Agent 服务的典型情况)→ CPU 核数 甚至更少就够,瓶颈在网络;CPU 密集 → 核数 到 核数+1。
别照搬 2×核数+1——那是给同步阻塞框架的公式。FastAPI 的 async def 在单个 worker 内就能并发处理大量等待中的请求(§6.6 实测:串行 2.47 秒的活并发 0.33 秒,快 7.6 倍)。先把 async 写对,再考虑加 worker。
陷阱:多 worker 不共享内存,模块级的全局缓存会变成 N 份;notekit 的 JSON 文件存储在多 worker 下会并发写覆盖丢数据(这是示例的已知局限),真实项目用 SQLite 或 Postgres。
15. 循环导入怎么解决?
根本办法是保持单向依赖。notekit 的依赖链是 models ← store ← cli/web,models 谁都不依赖——所以不可能出现循环。
真出现了,三个办法按推荐度排:① 重新设计分层(把公共部分抽到第三个模块);② 把 import 挪进函数内部(延迟导入,cli.py 里的 import uvicorn 就是这么写的,顺带还加快了启动);③ if TYPE_CHECKING: + 字符串注解(只为类型检查而 import)。
加分:报错信息是 ImportError: cannot import name X (most likely due to a circular import),最后那句提示直接告诉你原因。
十五、划重点
一、心态先调对。 你已经会 90% 的编程了。这一章真正的新东西只有三块:Python 的语法习惯、"Python 的 Gradle" = uv + pyproject.toml、一条从空目录到容器的完整通路。其余都是你已有知识的换皮。
二、最大的思维转变:编译器不再帮你兜底。
| Kotlin 里编译器帮你挡的 | Python 里谁来挡 |
|---|---|
| 类型错误 | mypy(必须单独跑,§7 实测 Python 自己不管) |
| 空指针 | mypy 的 X \| None + 你自己判空 |
| 拼错的变量名 | ruff 的 F 规则 |
| 逻辑错误 | pytest |
所以 ruff check + mypy + pytest 三道门不是"加分项",是把编译器的活补回来。新项目第一天就配好。
三、五条会反复救你的规则:
is只用来比None,值比较永远用==。- 默认参数、类属性、二维列表:可变对象绝不共享——用
default_factory、用推导式。 - 要区分"没传"和"传了 None"就必须显式判断(
is None/exclude_unset=True)。 - 时间内部永远用
datetime.now(UTC),只在给人看的时候转本地时区。 - 文本文件读写永远显式
encoding="utf-8"。
四、并发的三句话(§6/§8 全部实测过):
- CPU 密集 → 多进程(多线程被 GIL 挡住,0.70 → 0.67 毫无改善;进程 0.22)
- IO 密集 → async 优先,线程次之(1.22 → 0.30)
async def里一旦出现阻塞调用,async 就白写了(0.30 → 0.61,完全串行)
写 Agent 服务基本只需要 asyncio——因为瓶颈永远是等大模型返回。
五、工程链路一张图:
空目录
↓ uv init --package
项目骨架(src-layout + pyproject.toml)
↓ uv add / uv add --dev
依赖装好,uv.lock 生成
↓ 写代码:models → store → config → cli/web → tests
↓ ruff check . && mypy src && pytest -q ← 三道门
能跑且质量可控
↓ uv build
wheel(7085 字节,12 个文件,不含 tests 和 .env)
↓ uv pip install 到全新环境验证 / uv tool install / uv publish
别人能装上
↓ docker build(多阶段 + 层缓存 + 非 root + HEALTHCHECK)
镜像(355MB)
↓ docker compose up -d
服务在跑(healthy),数据在卷里活过容器重建
这条链路上每一步我都跑过并贴了真实输出(16 个 ✅ Checkpoint)。你照着敲一遍,就有了一个可以复用一辈子的项目模板。
六、部署与发布的硬规则:
- 库声明范围(
>=),应用锁死(uv.lock+--frozen)。 - 容器里
--host 0.0.0.0+PYTHONUNBUFFERED=1,这两个不加就一定出问题。 .env同时要进.gitignore和.dockerignore。- PyPI 版本号发出去就不能改,所以先发 TestPyPI。
- 不用 root 跑容器(
USER appuser)。 - 持久化必须用
down+up验证,restart验证不出问题。
七、别把示例代码当生产代码。 我在文中标出了 notekit 的两处已知局限:JSON 文件存储在多 worker 下会并发写丢数据(该用 SQLite/Postgres)、Web API 完全没有认证(对外提供服务前必须加)。能识别"教学代码到生产代码之间还差什么",本身就是工程能力。
八、接下来学什么。 按投入产出比排序:
| 优先级 | 内容 | 为什么 |
|---|---|---|
| 高 | sqlite3 / SQLAlchemy |
JSON 文件存储撑不过多进程,这是下一个必然遇到的问题 |
| 高 | asyncio 深入(TaskGroup、超时、取消) |
Agent 服务的核心,gather 只是入门 |
| 高 | pytest 进阶(monkeypatch、mock、conftest.py) |
测 LLM 调用必须会 mock |
| 中 | FastAPI 的认证与中间件 | 对外提供服务的前提 |
| 中 | logging 的结构化输出(JSON 日志) |
生产排查问题的基础 |
| 中 | CI(GitHub Actions) | 把三道门自动化 |
| 低 | 元类、描述符、__slots__ |
写框架才需要,写业务基本用不到 |
别去追元编程。 把上面"高"的三项吃透,你的 Python 水平就足以支撑本书全部章节的实战了。
十六、下一章预告
到这里,Python 这门语言和它的工程链路对你不再是障碍。
回到主线:本书正文从第 1 章的"什么是 Agent"一路讲到第 12 章的多 Agent 系统。如果你是从这个附录进来的,建议按下面的顺序回到正文:
| 你现在想干什么 | 去哪一章 |
|---|---|
| 搞清楚 Agent 到底是什么、和 Chatbot 差在哪 | 第 1 章 |
| 理解大模型内部怎么工作、参数怎么调 | 第 2、3 章 |
| 动手写第一个能用工具的 Agent(ReAct) | 第 4 章 ← 大多数人应该从这里开始 |
| 不想写代码,先用平台搭一个试试 | 第 5 章 |
| 给 Agent 加记忆和知识库(RAG) | 第 8 章 |
| 让 Agent 之间互相通信(MCP / A2A) | 第 10 章 |
| 微调自己的模型 | 第 11 章 |
| 做多 Agent 协作系统 | 第 12 章 |
你在本章练出的这些东西,正文里会立刻用到:
- §6 的
async/await→ 并发调多个大模型、并行跑多个工具 - §9 的 pydantic → 定义工具的参数 schema、校验模型返回的 JSON
- §9 的 httpx → 调所有大模型 API(记得设
timeout、复用Client) - §4 的异常处理 → 每个工具独立
try/except,一个工具挂了不能拖垮整个 Agent - §8 的
deque(maxlen=N)→ 对话历史的滑动窗口 - §8 的
dict.get()纪律 → 解析模型返回的 JSON 时不炸KeyError - §10 的 FastAPI + 分层 → 把 Agent 包装成 HTTP 服务
- §12 的 Docker → 把 Agent 部署出去
- §13 的读 Traceback → 你会用得非常频繁
最后一句:Python 的语法两天就能看完,但"写出别人能维护、跑得住、部署得出去的 Python"需要的是本章后半部分那些东西——分层、类型检查、测试、锁文件、容器、密钥纪律。这些你在 Android 开发里已经有对应的直觉,现在只是换了一套工具名字。