你有没有过这种经历:写了个挺顺手的脚本,扔给同事,对方第一句话是”这个怎么跑”。
你说:先建虚拟环境,装个 requests 和 rich,版本别装错……对方听到一半就去忙别的了。或者你自己半年后回来翻这个脚本,看着一堆 import 愣住——当时装的到底是哪个版本。
脚本这种东西,本来就是随手写的,凭什么要配一整套依赖管理?
PEP 723 想解决的就是这件事:让依赖声明直接住在脚本文件里。文件走到哪,依赖就跟到哪。配上 uv,这件事现在是真能跑通。
一段没用的开场:先看它长什么样
一个带依赖的单文件脚本,结构是这样的:
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "rich>=13.7",
# ]
# ///
import sys
from rich import print
print("[bold green]跑起来了[/bold green]", sys.version)
存成 demo.py,然后:
uv run demo.py
第一次跑的时候你会看到几行解析输出的日志,之后就是 跑起来了 3.12.x 这类正常输出。没有虚拟环境、没有 requirements.txt、没有 pip install。uv 会读那个 # /// script 块,把依赖拉到用户的全局缓存里,然后执行。
整个流程里多余的步骤只有一步——把依赖写进文件头。剩下的 uv 全包了。
PEP 723 到底规定了什么
规则本身短得可以背下来:
- 脚本里嵌入一段 TOML,用
# /// script开头、# ///结尾。 - 里面是一个普通的 TOML 表,
dependencies是唯一必须支持的键。 requires-python可选,用来约束解释器版本区间。- 其它键随便写,工具看到了会忽略,但对自己保留用途。
三点会绊人。
第一,每行的 # 后面要有一个空格。写 #/// script 没有空格,大多数工具识别不出来,会把它当成普通注释直接跳过。这个错误不报任何警告,你只是会发现依赖没生效,然后一脸茫然地查半天。
第二,末尾那个 # /// 不能漏。只有一个开头的块,解析器会一直读到文件结尾去找结束标记,大概率报个 TOML 语法错误。
第三,一个文件里只允许有一个这样的块。多写一个,规范里对行为没定义,不同工具反应不一样。
先把 uv 装好
uv 的安装按官方文档来就行,一条命令。macOS 和 Linux 上:
curl -LsSf https://astral.sh/uv/install.sh | sh
Windows 走 PowerShell 版本,或者直接 pipx install uv。
装好之后确认一下:
uv --version
顺便把 Python 也交给 uv 管。这样后面脚本里写 requires-python = ">=3.11",uv 自己会确保有这个版本的解释器可用,不需要你去系统里装:
uv python install 3.12
给脚本加依赖:别手改,让工具来
写 TOML 是很容易出错的,尤其是版本约束带引号那部分。uv 提供了专门的子命令:
uv add --script demo.py requests httpx
执行完之后打开 demo.py,你会看到依赖块被自动更新了,格式也对齐了。如果脚本里还没有这个块,uv 会帮你创建:
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "httpx",
# "requests",
# "rich>=13.7",
# ]
# ///
删依赖也是同一个命令反过来写:
uv remove --script demo.py requests
这个命令的好处是它顺带做了两件事:一是格式统一(别人看你的脚本不用适应各种缩进风格),二是它不会误删别的键。手改的话偶尔会手一抖把 requires-python 那行弄没。
临时依赖:–with
有些依赖你只是临时想用一下,不想写进脚本。比如临时加个调试输出:
uv run --with rich script.py
这条命令会在执行时把 rich 加进环境,脚本文件一个字符都不动。写一次性数据处理脚本时特别舒服——随手 --with pandas 或者 --with polars,跑完就完了。
--with 可以叠加多次,也可以传版本约束:
uv run --with "pandas>=2.2" --with rich analyze.py
有个细节要注意:--with 和脚本里已有的依赖块是叠加关系,不是覆盖。所以如果你脚本里已经写了 rich,命令行又加了 rich,不会冲突,uv 会选一个兼容的版本装上。
完整案例:一个自带依赖的订单汇总脚本
光讲 API 不如直接做一个能用的东西。假设运营同事每天早上甩给你一份订单 CSV,你想一键输出一张按客户汇总的表。
CSV 长这样:
order_id,customer,amount,created_at
A1001,张三,1299.00,2025-11-20
A1002,李四,89.50,2025-11-20
A1003,张三,420.00,2025-11-21
A1004,王五,0.00,2025-11-21
A1005,李四,1580.75,2025-11-21
A1006,张三,3200.00,2025-11-22
需求:按客户汇总订单数和总额,倒序排列,带点颜色,别太丑。
写脚本之前先用 uv 把依赖加上:
uv add --script report.py rich
然后脚本全文:
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "rich>=13.7",
# ]
# ///
import csv
import sys
from collections import defaultdict
from pathlib import Path
from rich.console import Console
from rich.table import Table
console = Console()
def load_orders(path: Path) -> list[dict[str, str]]:
with path.open(newline="", encoding="utf-8") as f:
return list(csv.DictReader(f))
def aggregate(rows: list[dict[str, str]]) -> dict[str, dict[str, float]]:
bucket: dict[str, dict[str, float]] = defaultdict(
lambda: {"count": 0, "total": 0.0}
)
for row in rows:
customer = row["customer"].strip()
if not customer:
continue
amount = float(row["amount"])
bucket[customer]["count"] += 1
bucket[customer]["total"] += amount
return dict(bucket)
def render(summary: dict[str, dict[str, float]]) -> None:
table = Table(title="客户消费汇总")
table.add_column("客户", style="cyan", no_wrap=True)
table.add_column("订单数", justify="right")
table.add_column("总金额", justify="right", style="green")
ranked = sorted(summary.items(), key=lambda kv: -kv[1]["total"])
for name, info in ranked:
table.add_row(name, str(int(info["count"])), f"¥{info['total']:.2f}")
console.print(table)
def main(argv: list[str]) -> int:
if len(argv) != 2:
console.print("[red]用法:[/red] report.py <orders.csv>")
return 1
path = Path(argv[1])
if not path.exists():
console.print(f"[red]文件不存在:[/red] {path}")
return 1
rows = load_orders(path)
if not rows:
console.print("[yellow]订单为空。[/yellow]")
return 0
render(aggregate(rows))
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
跑起来看看:
uv run report.py orders.csv
输出大概是这样,图标和颜色在终端里才会显示:
客户消费汇总
┏━━━━━━━┳━━━━━━━┳━━━━━━━━━━┓
┃ 客户 ┃ 订单数 ┃ 总金额 ┃
┡━━━━━━━╇━━━━━━━╇━━━━━━━━━━┩
│ 张三 │ 3 │ ¥4919.00 │
│ 李四 │ 2 │ ¥1670.25 │
│ 王五 │ 1 │ ¥0.00 │
└───────┴───────┴──────────┘
把这个文件发给别人,只要对方装了 uv,uv run report.py orders.csv 就能跑,不需要解释任何环境。
顺便说一下脚本里的几个选择。csv.DictReader 而不是手写 split(","),是因为真实 CSV 里经常有引号包裹的字段,里面有逗号也很正常。chart 里的金额用 float 处理,对演示够用,但如果你要处理真实财务数据,记得换成 decimal.Decimal,float 累加几次就会有精度漂移。
shebang 那行是怎么回事
文件头那行 #!/usr/bin/env -S uv run --script 不是摆设。它在 Linux 和 macOS 上的作用是:给这个文件加可执行权限之后,直接 ./report.py orders.csv 就能跑起来。
chmod +x report.py
./report.py orders.csv
-S 是 env 的参数,意思是”后面那串作为一个整体传给我,别按空格切”。没有它,内核会把 uv 和 run 当成两个不同的程序名,直接报错。
Windows 下这行会被忽略(当作注释处理),也没什么影响。要用的时候还是 uv run。
有一个小坑:有些精简过的 Linux 发行版里,env 是没有 -S 选项的。这种情况要么换成写死 uv 的绝对路径,要么就老老实实敲 uv run。主流发行版现在都支持 -S,问题不大。
requires-python 怎么选
我见过有人为了保险一律写 >=3.8,也有人一定要写当前最新版。两个极端都不太好。
写得低一点的代价是:你没法用较新的语法。比如 match 语句需要 3.10,typing.Self 需要 3.11,PEP 695 的类型参数语法需要 3.12。脚本里明明能写得短一点的地方,为了迁就老版本就得绕圈。
写得高一点的代价是:跑别人的机器上可能直接报”找不到符合要求的解释器”,不如直接跑起来再报错友好。
我的习惯是看脚本里实际用到了什么新特性。用了类型参数语法就写 >=3.12,用了 ExceptionGroup 就 >=3.11,没用到什么特别的东西就 >=3.11 起步。3.11 是个很稳的基线,性能也比 3.10 好一截。写到 3.9 以下基本没必要了,因为 3.9 早就停维护了。
不写 requires-python 也行,uv 会用当前默认解释器跑。但脚本一旦发送出去,这种”随缘解释器”的行为就不可控了。要么写死,要么写清楚。
锁文件和”版本漂移”
这里要分清楚两件事。dependencies 里的是版本约束,不是锁定的具体版本。你写 "rich>=13.7",今天跑可能装的是 13.9,明年跑可能装的是 14.2。
大多数脚本无所谓。但如果脚本要跑在别人的机器上、而且结果必须一致,就得考虑锁定。uv 支持给脚本生成锁文件:
uv lock --script report.py
跑完之后你会看到目录里多出一个 report.py.lock。以后 uv run 会自动读取这个锁文件,保证每次装的版本一致。
锁文件要不要提交到仓库,看你更在意什么。像定时任务、数据处理这种”跑出来结果必须一致”的场景,建议提交。纯本地小工具,不提交也行,反正影响面就自己一个人。
不装 uv 怎么办
PEP 723 是标准,不是 uv 独占。目前支持它的工具还有几个。
pipx 从 1.4 开始支持 pipx run script.py,会自动读取内联的依赖块。
pip-run 也能处理,不过它本身的用法和 uv 不太一样,得稍微适应一下。
Hatch 通过 hatch run 也认这个格式。
编辑器这边,PyCharm 和 VS Code 现在都能识别 # /// script 块,给出依赖提示,装完之后 import 不会标红。不过编辑器里的自动补全和”真实能不能跑”是两回事,写完之后还是 uv run 跑一遍确认一下比较稳。
几个容易踩的坑
编辑器把 TOML 块格式化了
有些 Python 格式化工具不认识这段注释里的 TOML,会当成普通注释去重排缩进,或者干脆把它合并成一行。一合并,块就废了,uv 直接跳过,脚本会因为没有依赖而报 ModuleNotFoundError。
我一般会在项目根目录放一个 .editorconfig,或者干脆用 ruff format 处理,它不会动注释里的内容。Black 也别去碰这段,它会以为是在做”注释对齐”。
依赖块放在哪
规范里没有硬性规定,但约定俗成的位置是:紧跟在 shebang 之后,在所有 import 之前。放在文件末尾、或者放在函数定义中间,工具能找到,但读代码的人会看得莫名其妙。
另外,如果脚本有模块级文档字符串,那段字符串习惯上也放在依赖块下面、import 上面。不过不同工具对”在文档字符串之前还是之后”处理略有差异。我一般偷懒直接让依赖块就在最顶上,省心。
脚本放在 stdin 里没法用
PEP 723 靠的是文件里的注释,所以从命令行管道传进来的代码,或者 python -c "..." 里的代码,没法带依赖。这时候就用 uv run --with。这算是 uv 的一个补充通道。
依赖装得太重
第一次跑一个带 pandas 的脚本,uv 会下几百兆的轮子。这不是 PEP 723 的问题,是 pandas 本来就这样。但初次使用的心理落差确实存在——”我只是想跑个 20 行脚本啊”。
如果是临时用一下,用 uv run --with,别写进脚本。如果长期用,写进去也无所谓,反正第一次之后就是缓存命中了。
我个人觉得这个组合最爽的地方
不是”不用配 venv”这件事本身。venv 也没有那么麻烦,习惯了也就过去了。
真正爽的地方是,脚本变成了一个自包含的实体。
以前写一个自动化小脚本,得在脑子里维护一张”这个东西需要什么”的映射。半年后回来,跑起来报错,第一反应是”我是不是装了什么还是没装”。现在打开文件扫一眼头部就知道依赖是什么,requires-python 是多少,不需要从别处找信息。
这对”扔给同事就能用”这件事的提升是质变的。以前发脚本要连带发一份 README 讲怎么装,现在发一个文件就完了。省下来的那些沟通成本,才是这个规范的价值。
如果你的脚本库还比较小,从下一个开始试试把依赖写进文件头吧。改一次就知道值不值。

