PEP 723 与 uv 实战:让单文件 Python 脚本自带依赖

2026-10-12 0 241

你有没有过这种经历:写了个挺顺手的脚本,扔给同事,对方第一句话是”这个怎么跑”。

你说:先建虚拟环境,装个 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 讲怎么装,现在发一个文件就完了。省下来的那些沟通成本,才是这个规范的价值。

如果你的脚本库还比较小,从下一个开始试试把依赖写进文件头吧。改一次就知道值不值。

PEP 723 与 uv 实战:让单文件 Python 脚本自带依赖
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 python PEP 723 与 uv 实战:让单文件 Python 脚本自带依赖 https://www.taomawang.com/server/python/2925.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务