uv 搭配 PEP 723:让 Python 脚本自己携带依赖,复制就能跑

2026-09-12 0 834

写完一个几十行的 Python 小脚本,发给同事用,对方第一句话往往是:“要装什么包?”然后就是一段尴尬的对话——你可能已经忘了当时装过什么,或者本地版本和对方跑出来的结果对不上。项目里有 requirements.txt 还好,可一个单独的工具脚本,实在不想为它建一个目录、写 pyproject.toml、再配个虚拟环境。

PEP 723 就是冲着这个小场景来的:把脚本需要的依赖声明写进脚本自己的注释里。uv 是目前对这套规范支持最完整的工具,本文用一个真实的日志统计脚本,把从零到可分发的过程走一遍。

PEP 723 规定的其实只是一个注释块

先明确一件事:PEP 723 本身不定义“怎么执行”,它只规定了“元数据放在哪、长什么样”。也就是说,任何工具都可以读它,但读完之后做什么,由工具自己决定。

格式是这样的:

# /// script
# requires-python = ">=3.11"
# dependencies = [
#   "requests",
# ]
# ///

三行标记之间是 TOML 内容,本质上是 pyproject.toml[project] 表的一个子集。requires-pythondependencies 是最常用的两个键,其余的键(比如 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 命令要大得多。

如果你手上正好有几个“装了依赖才能跑”的老脚本,花十分钟给它们加上元数据块,下次再发给别人时就不用解释了。

uv 搭配 PEP 723:让 Python 脚本自己携带依赖,复制就能跑
收藏 (0) 打赏

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

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

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

淘吗网 python uv 搭配 PEP 723:让 Python 脚本自己携带依赖,复制就能跑 https://www.taomawang.com/server/python/2747.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

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

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