写完一个几十行的 Python 小脚本,发给同事用,对方第一句话往往是:“要装什么包?”然后就是一段尴尬的对话——你可能已经忘了当时装过什么,或者本地版本和对方跑出来的结果对不上。项目里有 requirements.txt 还好,可一个单独的工具脚本,实在不想为它建一个目录、写 pyproject.toml、再配个虚拟环境。
PEP 723 就是冲着这个小场景来的:把脚本需要的依赖声明写进脚本自己的注释里。uv 是目前对这套规范支持最完整的工具,本文用一个真实的日志统计脚本,把从零到可分发的过程走一遍。
PEP 723 规定的其实只是一个注释块
先明确一件事:PEP 723 本身不定义“怎么执行”,它只规定了“元数据放在哪、长什么样”。也就是说,任何工具都可以读它,但读完之后做什么,由工具自己决定。
格式是这样的:
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "requests",
# ]
# ///
三行标记之间是 TOML 内容,本质上是 pyproject.toml 里 [project] 表的一个子集。requires-python 和 dependencies 是最常用的两个键,其余的键(比如 tool.uv 下的配置)由具体工具解释。
几个容易忽略的细节:起始行必须是 # /// script,多一个空格、少一个 # 都会导致解析失败;结束行是 # ///,没有 script 后缀。这玩意儿对格式比较敏感,写错了 uv 不会给你任何提示,就当作普通注释忽略了。
先把 uv 装上
uv 是单个二进制文件,装起来不挑环境:
# macOS / Linux
curl -LsSf https://astral.sh/uv/install.sh | sh
# Windows PowerShell
powershell -c "irm https://astral.sh/uv/install.ps1 | iex"
装完 uv --version 能看到版本号就行。uv 自带 Python 版本管理,后面遇到 requires-python 指定了本地没有的版本时,它会自己下载一份,不需要你手动折腾 pyenv。
第一个带内联依赖的脚本
新建 hello_uv.py:
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "rich>=13.7",
# ]
# ///
from rich.console import Console
console = Console()
console.print("[bold green]依赖是写在文件里的[/bold green]")
直接跑:
uv run hello_uv.py
第一次运行会看到 uv 在解析依赖、下载 wheel,然后输出结果。整个过程里你没有 pip install,没有 venv,也没有往全局环境里塞任何东西。
这里有个值得说一下的点:uv 会把这个脚本的依赖集合单独缓存起来。如果你同时有十个内联依赖各不相同的小脚本,它们的环境是互相隔离的,不会打架。
让它变成一个能直接执行的命令
加一行 shebang,再给个执行权限,脚本就变成命令了:
#!/usr/bin/env -S uv run
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "rich>=13.7",
# ]
# ///
...
chmod +x hello_uv.py
./hello_uv.py
env -S 的作用是把 uv run 当成两个参数传给 env,这是很多老系统上必须的写法,不加在某些 shell 里会报“找不到名为 uv run 的文件”。
如果脚本的用途是固定的,建议把它软链到 ~/.local/bin 之类的目录,以后在任何地方敲名字就能用。
实战:一个 nginx 日志统计脚本
假设你手上有一份 access.log,想快速看一下状态码分布、最热的路径和最活跃的 IP。这类需求写脚本最合适,但用一次就扔又太可惜,正好拿来做示例。
保存为 logstat.py:
#!/usr/bin/env -S uv run
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "rich>=13.7",
# ]
# ///
"""按状态码 / 路径 / IP 统计 nginx access log。"""
from __future__ import annotations
import argparse
import gzip
import re
import sys
from collections import Counter
from pathlib import Path
from rich.console import Console
from rich.table import Table
LINE_RE = re.compile(
r'^(?P<ip>S+) S+ S+ [(?P<time>[^]]+)] '
r'"(?P<method>[A-Z]+) (?P<path>S*) [^"]*" '
r'(?P<status>d{3}) (?P<size>d+|-)'
)
def open_log(path: Path):
if path.suffix == ".gz":
return gzip.open(path, "rt", encoding="utf-8", errors="replace")
return path.open("r", encoding="utf-8", errors="replace")
def parse(path: Path):
with open_log(path) as fh:
for raw in fh:
m = LINE_RE.match(raw)
if not m:
continue
yield {
"ip": m["ip"],
"path": m["path"],
"status": int(m["status"]),
"size": 0 if m["size"] == "-" else int(m["size"]),
}
def make_table(title: str, column: str, counter: Counter, total: int, limit: int) -> Table:
table = Table(title=f"{title}(样本 {total} 条)", title_justify="left")
table.add_column(column, overflow="fold", no_wrap=False)
table.add_column("次数", justify="right")
table.add_column("占比", justify="right")
for key, count in counter.most_common(limit):
table.add_row(str(key), str(count), f"{count / total:.1%}")
return table
def main() -> int:
parser = argparse.ArgumentParser(description="nginx access log 快速统计")
parser.add_argument("logfile", type=Path)
parser.add_argument("-n", "--top", type=int, default=8, help="每张表显示多少行")
args = parser.parse_args()
if not args.logfile.exists():
print(f"文件不存在:{args.logfile}", file=sys.stderr)
return 1
records = list(parse(args.logfile))
if not records:
print("没有解析出任何日志行,检查一下格式", file=sys.stderr)
return 1
total = len(records)
status = Counter(r["status"] for r in records)
paths = Counter(r["path"] for r in records)
ips = Counter(r["ip"] for r in records)
traffic = sum(r["size"] for r in records)
server_errors = sum(1 for r in records if r["status"] >= 500)
console = Console()
console.print(make_table("状态码分布", "状态码", status, total, args.top))
console.print(make_table("访问最多的路径", "路径", paths, total, args.top))
console.print(make_table("访问最多的来源 IP", "来源 IP", ips, total, args.top))
console.print(
f"n总出流量 {traffic / 1024 / 1024:.2f} MiB,"
f"5xx 占比 {server_errors / total:.2%}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
运行:
uv run logstat.py access.log -n 5
输出大致是这样:
状态码分布(样本 1000 条)
┏━━━━━━━━┳━━━━━━┳━━━━━━━┓
┃ 状态码 ┃ 次数 ┃ 占比 ┃
┡━━━━━━━━╇━━━━━━╇━━━━━━━┩
│ 200 │ 812 │ 81.2% │
│ 404 │ 96 │ 9.6% │
│ 301 │ 51 │ 5.1% │
└────────┴──────┴───────┘
...
这个脚本里有几个刻意的设计,值得单独说一下。
正则用命名组。日志格式一改,你只要动正则那一处,下面的统计逻辑完全不用碰。另外我把路径部分写成 S* 而不是 S+,因为形如 GET HTTP/1.1 这种畸形请求在真实日志里确实存在,用 + 会让整行匹配失败被丢掉。
匹配失败就跳过,不报错。日志文件里混着 error.log 的行、被截断的行都很正常。统计脚本的目标是给出趋势,不是做数据校验,丢掉几行不影响结论。
支持 .gz。运维机器上的老日志基本都是压缩过的,判断一下后缀就行,代码只多三行。
流量统计的边界。nginx 日志里的 size 是响应体大小,不含响应头。如果你的目的是排查带宽成本,这个值会偏小,看趋势够用,算账单不够。
后续怎么改依赖
手写 TOML 很容易忘掉逗号或引号,uv 提供了两个命令来操作。
给已有脚本加依赖:
uv add --script logstat.py pandas
uv 会自动帮你改写元数据块,并且顺手把依赖解析一遍,确认能装上才写进去。这个比手改靠谱得多。
临时用一次某个包、不想写进元数据:
uv run --with httpx logstat.py access.log
适合做一次性试验。比如你想验证某个解析库是不是更合适,先用 --with 试,确认要留下了再用 uv add --script 固化。
锁文件,以及为什么第二次运行几乎是瞬时的
第一次运行之后,脚本旁边会多出一个锁文件。它固定了这次解析出来的所有依赖版本,包括间接依赖。下次运行 uv 发现元数据没变,就直接按锁文件还原,速度非常快。
这件事的意义不只是快。把脚本和锁文件一起提交到仓库,半年后换一台机器跑,得到的是同一套依赖版本。这解决了“本地能跑线上报错”的一大部分场景。
缓存目录默认在 uv 的全局缓存里,按内容哈希分目录,多个脚本共用同一个依赖时不会重复下载。
几个真实踩过的坑
在项目目录里运行会继承项目依赖。如果当前目录有 pyproject.toml,而你的脚本又没写 dependencies,uv 可能会把项目依赖当成脚本的依赖。想让脚本彻底独立,加参数:
uv run --no-project logstat.py access.log
写 shebang 的时候也可以直接带上,避免以后忘。
元数据块的位置有讲究。它应该出现在文件最前面,在模块 docstring 之前或之后都行,但不要夹在代码中间——那样某些工具会解析不到。养成固定放最上面的习惯最省心。
requires-python 写太宽会失去意义。如果脚本用了 3.12 的语法特性,却写 >=3.9,uv 可能挑一个 3.9 的解释器然后报语法错误。写你实际测试过的下限。
别指望所有工具都认这个格式。PEP 723 是给“脚本运行器”看的,pip 不认,python script.py 也不认。脚本必须通过 uv run(或其他支持该规范的工具)启动,直接用解释器执行就只能靠运气了——本地恰好装过那些包才行。
这套方案适合什么,不适合什么
适合的场景:几十到几百行的运维脚本、数据处理小工具、面试题式的算法验证、给同事演示一个想法。这些场景的共同点是——不值得为它建一个项目,但确实需要外部依赖。
不适合的场景:要发布的库、有多个模块互相导入的应用、有测试和 CI 流水线的正式服务。这些东西老老实实用 pyproject.toml 建项目,uv 对标准项目管理流程的支持同样完整,没必要为了“单文件”把结构压扁。
另外要注意,脚本一旦超过四五百行,或者开始需要第二个文件,就该考虑拆成项目了。单文件脚本的甜点区在“一次性到偶尔复用”之间,不要硬撑。
最后
PEP 723 解决的其实是很小的一个问题:让脚本的依赖声明跟着脚本走。它没有引入新的包管理概念,也没有替代 pyproject.toml,只是把“需要什么”这件事挪到了离代码最近的地方。
配合 uv,从写下第一行注释到脚本能在别人机器上跑起来,中间不需要任何口头说明。这一点在团队协作里的价值,比省下来的那几条 pip install 命令要大得多。
如果你手上正好有几个“装了依赖才能跑”的老脚本,花十分钟给它们加上元数据块,下次再发给别人时就不用解释了。

