首页
/ uv run 完全解析:在 Python 项目中运行命令、注入临时依赖与 PEP 723 脚本隔离执行

uv run 完全解析:在 Python 项目中运行命令、注入临时依赖与 PEP 723 脚本隔离执行

2026-09-04 22:50:53作者:霍妲思

uv 是 Astral 用 Rust 编写的 Python 包与项目管理器,其中 uv run 是在项目环境中执行任意命令的统一入口。它会在运行前自动把项目环境同步到最新,并支持逐次调用注入额外依赖、隔离执行 PEP 723 内联元数据脚本、兼容 Windows 上的遗留 setuptools 脚本,以及一套经过精细设计的 Unix/Windows 信号转发机制。读完本文,你将能够掌握 uv run 的完整用法与参数体系,并理解其背后的环境同步、临时依赖分层与子进程信号处理等源码级实现。

在项目中运行命令:为什么需要 uv run

在一个 uv 项目中工作时,项目会被安装到虚拟环境 .venv 中。这个环境默认与当前 shell 隔离——也就是说,直接在你的 shell 里执行 python -c "import example" 这样的命令会失败,因为 shell 里的 python 找不到项目及其依赖。正确做法是使用 uv run 在项目中执行命令:

$ uv run python -c "import example"

使用 run 时,uv 会先确保项目环境是最新的(即与 pyproject.tomluv.lock 保持一致),然后再执行给定的命令。这就是“运行前先同步”的语义:

$ uv run python -m app

命令既可以来自项目环境,也可以位于项目环境之外。例如,假设项目声明了一个 example-cli 入口点:

$ # 项目提供了 example-cli 可执行入口
$ uv run example-cli foo

$ # 运行一个依赖项目已安装脚本的 bash 脚本
$ uv run bash scripts/foo.sh

第二种用法体现了 uv run 的通用性:它本质上是在“配置好的项目环境 + 增补后的 PATH”中启动任意子进程,而不限于 Python 解释器。从源码结构看,uv run 的完整实现在 run.rs,它负责发现项目/工作区、决定使用哪个环境、必要时同步依赖,最终把命令交给子进程等待函数 run_to_completion(定义在 child.rs)执行。

逐次调用注入额外依赖:--with 选项族

额外依赖,或依赖的不同版本,都可以按“每次调用”来请求,而不必修改 pyproject.toml--with 选项用于为本次调用添加一个依赖。例如请求 httpx 的不同版本:

$ uv run --with httpx==0.26.0 python -c "import httpx; print(httpx.__version__)"
0.26.0
$ uv run --with httpx==0.25.0 python -c "import httpx; print(httpx.__version__)"
0.25.0

请求的版本会被严格遵守,无论项目的自身要求是什么——即使项目声明了 httpx==0.24.0,上面的输出仍然一样。这一行为的关键在于分层环境:在 RunArgs 中,--with 的帮助文本明确写道,在项目内使用时这些依赖会“layered on top of the project environment in a separate, ephemeral environment”(叠加在项目环境之上的独立临时环境),且这些依赖被允许与项目声明的要求相互冲突。也就是说,--with 注入的包永远不会破坏你的 .venvuv.lock,它们只存活于本次调用的临时环境层中,调用结束后即被丢弃。

--with 家族还包括两个相关选项,从 CLI 定义中可以确认:

  • --with-editable:以可编辑模式安装临时依赖(lib.rs);
  • --with-requirements:从文件批量注入依赖,支持 requirements.txt、带内联元数据的 .py 文件以及 pylock.toml 三种格式;明确不允许使用 pyproject.tomlsetup.pysetup.cfglib.rs)。它与 --with 遵循相同的环境语义,即同样叠加在临时环境中。

配合这两个选项,典型用法如下:

$ # 用一次性文件批量注入测试工具链
$ uv run --with-requirements tools.txt pytest -q

$ # 临时以可编辑方式引入本地包
$ uv run --with-editable ../local-pkg python -c "import local_pkg"

运行脚本:PEP 723 内联元数据的隔离执行

声明了内联元数据(PEP 723 块)的脚本,会在与项目隔离的环境中自动执行。也就是说,当你直接运行一个带内联元数据的 .py 文件时,uv 不会把它当作项目的一部分,而是解析其 # /// script 块,构建一个只包含脚本声明依赖的独立环境。详见 scripts 指南

例如,给定脚本 example.py

# /// script
# dependencies = [
#   "httpx",
# ]
# ///

import httpx

resp = httpx.get("https://peps.python.org/api/peps.json")
data = resp.json()
print([(k, v["title"]) for k, v in data.items()][:10])

此时调用 uv run example.py 会以隔离方式运行——环境中只有脚本声明的那几个依赖,项目本身的环境不参与。这带来两个直接好处:脚本可以在任何目录(包括没有 pyproject.toml 的位置)自包含地运行;脚本依赖的版本漂移也不会污染项目环境。

从源码结构看,脚本处理在 run.rs 中通过 Pep723Item / Pep723Script 等类型完成,相关解析能力来自 uv-scripts crate。此外还有两个相关的显式开关(定义于 lib.rs):

  • --script / -s:强制把给定路径按 PEP 723 脚本解析,无论其扩展名是什么——即使文件不是 .py
  • --gui-script:同样按 PEP 723 脚本解析,但在 Windows 上使用 pythonw.exe 启动(无控制台窗口),仅限 Windows。

Windows 上的遗留脚本:.ps1 / .cmd / .bat 的自动发现

uv 支持遗留的 setuptools 脚本——这类脚本是 setuptools 在 .venv\Scripts 目录中额外安装的配套文件(用于在 Windows 控制台环境下运行包的可执行入口)。目前仅支持 .ps1.cmd.bat 三种扩展名。

例如,运行一个 Command Prompt 脚本(注意 -- 分隔符,其后是原样传递给子进程的参数):

$ uv run --with nuitka==2.6.7 -- nuitka.cmd --version

更实用的是,你甚至不需要写扩展名:uv 会自动按 .ps1.cmd.bat 的顺序替你查找并执行对应的文件:

$ uv run --with nuitka==2.6.7 -- nuitka --version

这一行为在源码中有精确对应:WindowsRunnable 定义了四类可运行对象——Executable.exe)、PowerShell.ps1)、Command.cmd)、Batch.bat)。其 as_command 方法揭示了 uv 实际如何启动它们:.ps1 通过 powershell -NoLogo -File <path> 执行,.cmd/.bat 通过 cmd /q /c <path> 执行。当用户未提供扩展名时,from_script_path 会按上述顺序在环境脚本目录中逐一探测候选文件。

信号处理:为什么 Ctrl-C 的行为是“精心设计”的

uv 不会把进程控制权完全交给被启动的命令(这样可以在失败时提供更友好的错误信息),因此 uv 自己承担了把部分信号转发给子进程的责任。

Unix 上的信号转发策略

在 Unix 系统上,uv 会转发绝大多数信号,例外是 SIGKILLSIGCHLDSIGIOSIGPOLL——后三者对父子进程关系没有实际意义,SIGKILL 则无法被捕获。真正微妙的是 SIGINT(Ctrl-C)的处理。终端驱动在按下 Ctrl-C 时向前台进程组发送 SIGINT,这意味着 uv 和子进程都会收到。如果 uv 再立即转发一次,子进程就会收到“两个 SIGINT”——而对某些程序,两次 SIGINT 有特定语义(第一次温和退出、第二次强制退出),这会破坏子进程自身的优雅退出逻辑。因此 uv 的策略是:

  • 仅当 SIGINT 被发送超过一次,或者子进程所在进程组与 uv 不同时,才转发给子进程;
  • 由于 uv 会延迟转发,终端可能仍在继续向进程组发送信号,所以转发前还会等待 200 毫秒,给子进程留出自行处理窗口期。

这些逻辑都能在 child.rsrun_to_completion 中逐条对上:它用 nix 读取父进程与子进程的 PGID,用 tokio::signal 注册 SIGINTSIGTERMSIGUSR1SIGUSR2SIGHUPSIGALRMSIGQUITSIGWINCHSIGPIPE(以及部分平台上的 SIGINFO)处理器,并以“stdin 是否为 TTY”作为判断是否处于交互式终端的启发式——非交互式场景下(例如 kill -2 <pid> 直接发给 uv),SIGINT 会被无条件转发。

SIGTERM 的处理同样值得注意:注释中解释了 PID 1 的特殊性——当 uv 在 Docker 容器里被直接作为 PID 1 启动时,内核给 PID 1 的默认 SIGTERM 处理器不会终止进程,因此 uv 必须无条件转发 SIGTERM,否则容器内的 uv 将无法被 docker stop 终止。

Windows 上的策略

在 Windows 上,上述进程组概念不适用。uv 会忽略 Ctrl-C 事件,把处理权完全交给子进程,让它有机会干净地退出——这与 Unix 下“子进程自己先处理第一次 SIGINT”的目标是一致的。

关键选项速查

uv run 的完整参数面在 RunArgs 中定义。下表汇总了与本文主题最相关、且直接影响“以何种环境运行命令”的选项:

选项 作用 说明
--with / -w 注入临时依赖 叠加在独立临时环境中,允许与项目要求冲突
--with-editable 以可编辑模式注入临时依赖 环境语义与 --with 相同
--with-requirements 从文件批量注入 支持 requirements.txt.py(内联元数据)、pylock.toml
--isolated 强制使用全新环境 不复用项目环境以换取严格隔离;项目仍以可编辑方式安装
--no-project / --no-workspace 跳过项目/工作区发现 在由 --with 填充的临时隔离环境中运行
--no-sync 跳过同步虚拟环境 隐含 --frozen,不再更新 lockfile
--frozen 不更新 uv.lock 直接以 lockfile 中的版本为准;lockfile 缺失时报错
--locked 断言 lockfile 不变 要求 lockfile 已是最新,否则以错误退出
--exact 精确同步 删除环境中多余(extraneous)的包;默认只做满足要求的最小变更
--module / -m 按 Python 模块运行 等价于 python -m <module>
--script / -s 强制按 PEP 723 脚本解析 无论扩展名
--gui-script 按 PEP 723 脚本解析并在 Windows 用 pythonw.exe 启动 仅 Windows
--all-packages 在包含全部工作区成员的环境中运行 更新后的 .venv 含所有工作区成员
--package 在工作区内指定某个包 目标成员不存在时报错
--python / -p 指定解释器 若满足请求的已被发现环境使用,则沿用该环境
--python-platform 指定目标平台(target triple) 面向交叉安装等进阶场景,安装结果可能与本机不兼容

其中 --isolated--no-project 的组合尤其值得留意:前者“仍发现项目、但每次用全新环境”(项目仍以可编辑方式装入该环境),后者则“根本不发现项目/工作区”,适合在仓库目录里跑一个与项目完全无关的一次性命令:

$ # 完全脱离当前项目,只用临时依赖跑命令
$ uv run --no-project --with requests python -c "import requests; print(requests.__version__)"

小结

uv run 的设计核心是三层能力的叠加:

  1. 环境即依赖:运行任何命令前自动完成项目环境同步(除非显式 --no-sync/--frozen),使 .venv 永远与声明一致;
  2. 临时依赖分层--with 系列选项把一次性依赖隔离在独立临时环境层,允许与项目要求冲突而不污染 uv.lock
  3. 脚本与平台兼容:PEP 723 内联元数据脚本自动隔离执行,Windows 遗留 .ps1/.cmd/.bat 脚本按固定顺序自动发现,Unix 信号转发策略在“错误可见性”与“子进程优雅退出”之间取得了平衡。

对于想深入实现细节的读者,建议沿着 run.rs(命令编排与环境选择)、child.rs(子进程等待与信号转发)以及 runnable.rs(Windows 可运行文件解析)三条路径继续阅读。

登录后查看全文
热门项目推荐
相关项目推荐