春江暮客

春江暮客的个人学习分享网站

用 uv 和 PEP 723 写一个自带依赖的 Python 单文件脚本

2026-06-02 技术
用 uv 和 PEP 723 写一个自带依赖的 Python 单文件脚本

很多自动化脚本一开始只是一个 tools.py,后来慢慢加上 requestsrichpandas 这类依赖。问题也随之出现:脚本发给别人以后,对方不知道该装哪些包;服务器上临时执行时,还要先创建虚拟环境。

uv 支持 PEP 723 内联脚本元数据,可以把 Python 版本和依赖直接写在 .py 文件顶部。本文用一个可运行的 daily_report.py 示例,演示如何把脚本做成“复制即运行”的工具。

完成后,你会得到:

  1. 一个带内联依赖的 Python 单文件脚本
  2. 一条 uv run daily_report.py 运行命令
  3. 一个可选的脚本锁文件
  4. 几个常见报错的直接修复方法

适合什么场景

这种方式适合小而独立的脚本:

  1. 日志分析、CSV 清洗、数据报表
  2. 运维巡检、定时任务、一次性迁移
  3. 分享给同事的小工具
  4. 不值得创建完整项目目录的自动化脚本

如果脚本已经发展成多模块项目,或者需要打包发布到 PyPI,建议继续使用 pyproject.toml 和普通 uv 项目。单文件脚本的优势是轻量和易分享,不是替代所有项目结构。

方法 1:临时指定依赖运行脚本

先看最简单的方式:不改脚本,只在运行时告诉 uv 需要哪些包。

新建 hello_rich.py

from rich.console import Console

console = Console()
console.print("[green]hello from uv[/green]")

直接运行会失败,因为系统环境里不一定安装了 rich

python3 hello_rich.py

常见报错类似:

ModuleNotFoundError: No module named 'rich'

uv run --with 可以临时创建隔离环境并运行:

uv run --with rich hello_rich.py

这种方式适合一次性测试。如果这个脚本要长期保存或分享,最好把依赖写进脚本本身。

方法 2:把依赖写进脚本

下面做一个更完整的例子:读取 tasks.csv,按状态统计任务数量,并用表格输出结果。

1. 准备 uv

如果还没有安装 uv,先执行:

curl -LsSf https://astral.sh/uv/install.sh | sh

重新打开终端后检查:

uv --version
uv help

2. 创建脚本文件

初始化一个带脚本元数据的文件:

uv init --script daily_report.py --python 3.12

添加这个脚本需要的依赖:

uv add --script daily_report.py rich python-dateutil

执行后,uv 会在 daily_report.py 顶部写入类似这样的元数据块:

# /// script
# requires-python = ">=3.12"
# dependencies = [
#   "python-dateutil",
#   "rich",
# ]
# ///

这个块就是 PEP 723 的核心:依赖跟着脚本走,不再需要额外发送 requirements.txt

实现:编写 daily_report.py

daily_report.py 改成下面的完整内容:

# /// script
# requires-python = ">=3.12"
# dependencies = [
#   "python-dateutil",
#   "rich",
# ]
# ///

from __future__ import annotations

import argparse
import csv
from collections import Counter
from pathlib import Path

from dateutil.parser import parse
from rich.console import Console
from rich.table import Table


console = Console()


def load_tasks(path: Path) -> list[dict[str, str]]:
    with path.open(newline="", encoding="utf-8") as file:
        return list(csv.DictReader(file))


def build_table(tasks: list[dict[str, str]]) -> Table:
    status_counts = Counter(task["status"] for task in tasks)
    due_dates = [parse(task["due"]) for task in tasks if task.get("due")]
    next_due = min(due_dates).date().isoformat() if due_dates else "n/a"

    table = Table(title="Daily Task Report")
    table.add_column("Metric")
    table.add_column("Value", justify="right")
    table.add_row("Total tasks", str(len(tasks)))
    table.add_row("Open", str(status_counts["open"]))
    table.add_row("Doing", str(status_counts["doing"]))
    table.add_row("Done", str(status_counts["done"]))
    table.add_row("Next due date", next_due)
    return table


def main() -> None:
    parser = argparse.ArgumentParser(description="Build a small task report.")
    parser.add_argument("csv_file", type=Path, help="CSV file with title,status,due columns")
    args = parser.parse_args()

    tasks = load_tasks(args.csv_file)
    console.print(build_table(tasks))


if __name__ == "__main__":
    main()

再准备一份测试数据:

cat > tasks.csv <<'EOF'
title,status,due
Write blog,done,2026-06-02
Check server,open,2026-06-03
Clean logs,doing,2026-06-04
Update docs,open,2026-06-05
EOF

运行脚本:

uv run daily_report.py tasks.csv

第一次运行时,uv 会读取脚本顶部的依赖,创建隔离环境,然后执行脚本。后续再运行会复用缓存,速度会快很多。

验证输出

正常输出应该类似下面这样:

     Daily Task Report
┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┓
┃ Metric        ┃      Value ┃
┡━━━━━━━━━━━━━━━╇━━━━━━━━━━━━┩
│ Total tasks   │          4 │
│ Open          │          2 │
│ Doing         │          1 │
│ Done          │          1 │
│ Next due date │ 2026-06-02 │
└───────────────┴────────────┘

还可以查看这个脚本的依赖树:

uv tree --script daily_report.py

如果你要把脚本放进生产环境或定时任务,建议生成锁文件:

uv lock --script daily_report.py

这会在旁边生成:

daily_report.py.lock

提交脚本时可以一起提交锁文件,让后续执行尽量复现相同依赖版本。

做成可执行命令

如果希望直接执行 ./daily_report.py,可以在文件第一行加 shebang:

#!/usr/bin/env -S uv run --script

完整文件开头应该像这样:

#!/usr/bin/env -S uv run --script
#
# /// script
# requires-python = ">=3.12"
# dependencies = [
#   "python-dateutil",
#   "rich",
# ]
# ///

然后加执行权限:

chmod +x daily_report.py
./daily_report.py tasks.csv

这对放在 ~/bin、项目 scripts/ 目录、CI 任务和服务器巡检脚本都很方便。

常见问题

ModuleNotFoundError

原因通常是直接用了 python daily_report.py,绕过了 uv

修复方式:

uv run daily_report.py tasks.csv

如果脚本里还缺依赖,用下面命令加入元数据:

uv add --script daily_report.py package-name

Python 版本不匹配

如果脚本写了:

# requires-python = ">=3.12"

但机器上没有合适版本,先安装:

uv python install 3.12
uv run daily_report.py tasks.csv

在项目目录里运行时依赖不对

带内联元数据的脚本会使用脚本自己的依赖,而不是当前项目的依赖。这样做的好处是脚本更独立;如果它确实需要项目代码,就应该改成普通项目命令,而不是单文件脚本。

权限不足

如果执行 ./daily_report.py 提示没有权限:

chmod +x daily_report.py

如果系统不支持 env -S,退回显式命令最稳:

uv run --script daily_report.py tasks.csv

总结

uv 加 PEP 723 很适合管理小型 Python 自动化脚本:依赖写在文件里,运行时由 uv 自动创建隔离环境。对于需要分享、定时运行或临时复制到服务器上的脚本,这比手工维护虚拟环境和 requirements.txt 更轻。实际使用时,建议先用 uv add --script 管理依赖,再用 uv lock --script 固定生产脚本的依赖版本。

友情链接

其它