2025-03-25 19:34:10 共 3384 字 阅读需 4 分钟
我们都喜欢 Python 全面的标准库,但不得不承认——PyPI 丰富的包资源常常变得不可或缺。分享依赖这些外部工具的单文件、自包含的 Python 脚本可能会令人头疼。过去,我们依赖 requirements.txt
或像 Poetry、pipenv 这样成熟的包管理器,但这对于简单的脚本来说可能过于繁琐,对新手来说也有些吓人。但如果有一种更简单的方法呢?这就是 uv 和 PEP 723 发挥作用的地方。本文深入探讨了 uv 如何利用 PEP 723 将依赖项直接嵌入脚本中,使得分发和执行变得极其容易。
uv 和 PEP 723
uv 及其下一代 Python 工具链中我最喜欢的功能之一是,能够运行包含外部 Python 包引用的单文件 Python 脚本,而无需繁琐的准备工作。uv 借助 PEP 723 实现了这一壮举,该 PEP 专注于“内联脚本元数据”。这个 PEP 定义了一种标准化方法,用于将脚本元数据(包括外部包依赖项)直接嵌入到单文件 Python 脚本中。
PEP 723 已经通过了 Python 增强提案流程,并得到了 Python 指导委员会的批准,现在已成为官方 Python 规范的一部分。Python 生态系统中的各种工具已经实现了对其的支持,包括 uv、PDM (Python Development Master) 和 Hatch。在本文中,我们重点关注 uv 对 PEP 723 的出色支持,以创建和分发单文件 Python 脚本。
准备工作
我们创建了一个名为 wordlookup.py
的 Python 脚本,用于从字典 API 获取定义。它看起来相当不错,但我们希望能够轻松地分发给其他人运行:
import httpx
import json
import argparse
import asyncio
import textwrap
import os
async def fetch_word_data(word: str) -> list:
"""Fetches word data from the dictionary API."""
url = f"https://api.dictionaryapi.dev/api/v2/entries/en/{word}"
try:
async with httpx.AsyncClient() as client:
response = await client.get(url)
response.raise_for_status()
return response.json()
except httpx.HTTPError:
return None
except json.JSONDecodeError as exc:
print(f"Error decoding JSON for '{word}': {exc}")
return None
except Exception as e:
print(f"An unexpected error occurred: {e}")
return None
async def main(word: str):
"""Fetches and prints definitions for a given word with wrapping."""
data = await fetch_word_data(word)
if data:
print(f"Definitions for '{word}':")
try:
# 4 for padding
terminal_width = os.get_terminal_size().columns - 4
except OSError:
# default if terminal size can't be determined
terminal_width = 80
for entry in data:
for meaning in entry.get("meanings", []):
part_of_speech = meaning.get("partOfSpeech")
definitions = meaning.get("definitions", [])
if part_of_speech and definitions:
print(f"\n{part_of_speech}:")
for definition_data in definitions:
definition = definition_data.get("definition")
if definition:
wrapped_lines = textwrap.wrap(
definition, width=terminal_width,
subsequent_indent=""
)
for i, line in enumerate(wrapped_lines):
if i == 0:
print(f"- {line}")
else:
print(f" {line}")
else:
print(f"Could not retrieve definition for '{word}'.")
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Fetch definitions for a word.")
parser.add_argument("word", type=str, help="The word to look up.")
args = parser.parse_args()
asyncio.run(main(args.word))
该脚本导入了几个 Python 模块,为与字典 API Web 服务交互、处理 JSON 数据、处理命令行参数、利用异步操作、格式化文本输出以及与操作系统交互以获取终端宽度奠定了基础。除了 httpx
(一个 HTTP 客户端库包)之外,我们导入的所有其他 Python 模块都是 Python 标准库的一部分。虽然理论上我可以使用 Python 内置的 urllib.request 模块来完成目标,但我更喜欢 httpx
。然而,这带来了一个困境,因为我需要一种好的方式来分发这个脚本,让我的朋友和同事可以轻松使用它,而无需费力安装所需的 httpx
依赖。
我们如何解决这个困境?uv 来救场!接下来我们将逐步介绍其工作原理。
注意:这篇文章在 Hacker News 上获得了大量关注并引发了一些很棒的讨论 (链接)。其中提出的一点是关于选择 Python 内置 Web 客户端选项与 httpx
或 requests
等库的比较。需要澄清的是,本文的重点是演示如何创建和分享依赖外部 Python 包的单文件 Python 脚本,而不是特别提倡使用哪种 Web 客户端。使用 httpx
仅仅是为了演示这个概念。
安装 uv
第一步,我们首先需要安装 uv。请参考官方 uv 文档获取 安装 uv 的指导。几种常见的安装 uv 的方法包括:
# 假设你已经安装了 pipx,这是推荐的方式,因为它将 uv 安装到隔离环境中
pipx install uv
# uv 也可以这样安装
pip install uv
uv 是一个功能极其强大且用途广泛的工具,在我看来,它很大程度上代表了 Python 工具的未来。然而,在本文中,我只演示了 uv 的一个很棒的功能,即调用带有外部依赖的单文件脚本。
使用 uv 在单文件脚本中添加包依赖
我们现在准备将 httpx
作为依赖项添加到我们的 wordlookup.py
脚本中!方法如下:
uv add --script wordlookup.py httpx
就是这样!之后,uv 会在脚本顶部的注释中添加元数据。以下是脚本的开头部分以及随后的几行,以便您可以看到实际效果:
# /// script
# requires-python = ">=3.13"
# dependencies = [
# "httpx",
# ]
# ///
import httpx
import json
import argparse
import asyncio
import textwrap
import os
async def fetch_word_data(word: str) -> list:
"""Fetches word data from the dictionary API."""
url = f"https://api.dictionaryapi.dev/api/v2/entries/en/{word}"
try:
async with httpx.AsyncClient() as client:
response = await client.get(url)
response.raise_for_status()
return response.json()
except httpx.HTTPError:
return None
except json.JSONDecodeError as exc:
print(f"Error decoding JSON for '{word}': {exc}")
return None
except Exception as e:
print(f"An unexpected error occurred: {e}")
return None
如果您曾使用过 pyproject.toml
文件配合各种 Python 工具(如 Poetry、Flit、Hatch、Maturin、setuptools 等),那么这种语法看起来至少会有些熟悉。例如,Poetry 的配置可能如下所示:
# <-- 其他包元数据在这里 -->
[tool.poetry.dependencies]
python = ">=3.13"
httpx = "^0.28.1"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
您会观察到 uv 添加了 httpx
的元数据,但没有指定版本。uv 将从 PyPI 获取最新稳定版本的 httpx 以供脚本使用。您可以通过事后直接修改元数据或通过命令行指定版本依赖来添加依赖约束:
uv add --script wordlookup.py "httpx>=0.28.1"
使用 uv 运行你的脚本
我们准备好运行脚本了。uv 工具使得运行它变得非常简单(请注意,我还向脚本传递了一个 --help
参数):
$ uv run wordlookup.py --help
Installed 7 packages in 74ms
usage: wordlookup.py [-h] word
Fetch definitions for a word.
positional arguments:
word The word to look up.
options:
-h, --help show this help message and exit
首次使用 uv run
调用脚本时,您会在开头看到一些额外的活动,因为 uv 会在后台自动创建一个隔离的虚拟环境,并获取和安装 httpx
包及其相关依赖项。这就是为什么我们在终端输出中看到 Installed 7 packages in 74ms
。
如果您尝试使用 python wordlookup.py
运行脚本,除非您恰好在全局或当前虚拟环境中安装了 httpx
,否则脚本将失败。uv 是如何使用脚本元数据的呢?当使用 uv run
调用脚本时,uv 会:
- 检查所需的 Python 版本是否可用。
- 自动创建一个隔离的虚拟环境(不会修改您的全局 Python 环境)。
- 如果尚未安装,则安装列出的依赖项(在本例中是
httpx
)。 - 执行脚本。
对于之后每次使用 uv run
启动脚本,uv 将利用其在后台创建的虚拟环境并调用脚本:
$ uv run wordlookup.py postulate
Definitions for 'postulate':
noun:
- Something assumed without proof as being self-evident or generally accepted, especially when used as a basis
for an argument. Sometimes distinguished from axioms as being relevant to a particular science or context,
rather than universally true, and following from other axioms rather than being an absolute assumption.
- A fundamental element; a basic principle.
- An axiom.
- A requirement; a prerequisite.
verb:
- To assume as a truthful or accurate premise or axiom, especially as a basis of an argument.
- To appoint or request one's appointment to an ecclesiastical office.
- To request, demand or claim for oneself.
adjective:
- Postulated.
如果我们向脚本添加额外的依赖项,或者在元数据中更改 Python 或 httpx
版本,uv run
将在下次调用时创建一个新的隔离虚拟环境。
使用 Python shebang 使运行更简单
我们可以在 Python 脚本的顶部添加一个 shebang(有时称为 hashbang),使得使用 uv 调用脚本更加容易。我是从 Trey Hunner 这里 学到的这个绝妙技巧。
Linux/macOS 用户
对于 Linux 和 macOS(以及 BSD 用户),在脚本顶部添加以下行:
#!/usr/bin/env -S uv run --script
完整的脚本上下文在文件顶部将如下所示:
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13"
# dependencies = [
# "httpx>=0.28.1",
# ]
# ///
import httpx
import json
import argparse
import asyncio
import textwrap
import os
接下来,使文件可执行:
chmod u+x wordlookup.py
完成后,您可以直接运行脚本,无需使用完整的 uv run wordlookup.py
命令:
./wordlookup.py --help
(译者注:原文这里直接用 ./wordlookup --help
,假设用户会移除 .py
扩展名。为保持一致性,暂保留 .py
,后续章节会提到重命名)
Windows 用户
对于 Windows 用户,您也很幸运,因为 Windows 的 py 启动器也能够解释 shebang。当您在 Windows 上安装 Python 时,py 启动器默认包含在内。请注意,您需要从 shebang 中省略 -S
才能使脚本正常工作。脚本的第一行应如下所示:
#!/usr/bin/env uv run --script
然后您可以在 Windows 上使用 py
命令按如下方式调用脚本:
py wordlookup.py --help
注意:如果您通过 python wordlookup.py
调用脚本,这将不起作用,因为 shebang 不会被解释。
设置你的 uv 脚本以便在计算机上的任何位置调用
要使您的 uv (Python) 脚本能够在系统上的任何位置轻松执行,您可以将其移动到包含在系统 PATH 中的常用可执行目录。
Linux/macOS 用户
对于 Linux 和 macOS 用户,将 wordlookup.py
脚本复制到您系统 $PATH
中的一个目录。在我的系统上,$HOME/bin
文件夹在路径中,我将其移动到了那里:
mv wordlookup.py ~/bin/
我还选择重命名文件并删除 .py 文件扩展名,使其调用起来更符合人体工程学,因为 shebang 包含了识别该文件为 Python 脚本所需的所有信息:
mv ~/bin/wordlookup.py ~/bin/wordlookup
我现在可以在任何地方调用它。(您还会观察到,当 Python 脚本首次从新位置调用时,uv 将创建一个新的虚拟环境并解析包依赖关系。)
$ wordlookup --help
Installed 7 packages in 21ms
usage: wordlookup.py [-h] word
Fetch definitions for a word.
positional arguments:
word The word to look up.
options:
-h, --help show this help message and exit
(译者注:这里原脚本名 wordlookup.py
出现在 usage 信息中,这是因为脚本内部 argparse
可能默认使用 sys.argv[0]
,即使文件被重命名,shebang 调用的可能还是原始路径或一个临时路径。但这不影响 wordlookup --help
命令本身能工作)
Windows 用户
对于 Windows 用户,您可以将脚本移动到系统 PATH
环境变量中已包含的目录之一,或者将一个新文件夹添加到 PATH
中。我将假设您创建了一个名为 c:\scripts
的文件夹并将其添加到了您的 PATH
。
接下来,创建一个名为 wordlookup.cmd
的文件并添加以下内容:
@echo off
py c:\scripts\wordlookup.py %*
然后您将能够在系统的任何位置从 Windows Terminal 或命令提示符中像这样调用脚本:
wordlookup --help
额外内容:uv 将其虚拟环境安装在哪里?
作为一个好奇的软件工程师,我决定深入研究,看看能否发现在我的 Fedora Linux 系统上 uv 将其虚拟环境安装在了哪里。毕竟,我的 wordlookup.py
放在它自己的专用目录中。运行 uv add --script
添加 httpx
包依赖元数据并调用 uv run
后,在本地文件夹中根本看不到像 .venv
这样的虚拟环境目录。
我首先通过查找系统上所有名为 httpx
的目录开始,因为在脚本创建后首次调用 uv run
时,很可能会创建一个以此命名的文件夹。
$ find ~ -type d -name httpx 2>/dev/null
~/.cache/uv/environments-v2/wordlookup-f6e73295bfd5f60b/lib/python3.13/site-packages/httpx
# <其他找到的文件夹,为简洁起见省略>
(译者注:修改了 find 命令,从用户主目录开始查找,并重定向了错误输出,更符合实际查找场景)
瞧!我在一个名为 ~/.cache/uv/environments-v2
的父文件夹中找到了一个名为 httpx
的文件夹。这看起来很有希望。
然后我发现了一个可以运行的命令 (uv cache clean) 来清除所有 uv 虚拟环境。这些操作是无害的,因为虚拟环境可以轻松地重新创建。
$ uv cache clean
Clearing cache at: /home/user/.cache/uv
Removed 848 files (8.2MiB)
(译者注:将示例路径调整为更通用的 /home/user/.cache/uv
)
为了在我的 Linux 系统上观察整个过程(也许这有点小题大做 ),我使用了
inotifywait
来监控当我调用 uv run wordlookup.py
时发生的所有文件创建事件,因为在我清除了缓存后,uv 需要重新创建它的虚拟环境。
inotifywait -m -r -e create ~/.cache/
# 当这个命令运行时并等待事件时,我从另一个终端窗口调用了 `uv run wordlookup.py`
inotifywait
命令(属于 inotify-tools
包)等待文件系统事件并输出它们。以下是我使用的参数:
-m
(monitor): 此选项告诉inotifywait
持续监控指定目录的事件。没有它,inotifywait
只会报告第一个事件然后退出。-r
(recursive): 此选项告诉inotifywait
递归地监控指定目录及其所有子目录的事件。在.cache/
或其任何子目录中创建的任何新文件或目录都将触发事件。-e create
(event: create): 此选项指定inotifywait
只应报告创建事件。当在受监控目录内创建新文件或目录时,会发生创建事件。~/.cache/
: 这是要求inotifywait
监控的目录。
果然,inotifywait
揭示了当 uv run wordlookup.py
启动时动态创建的文件夹。
当我将 wordlookup.py
脚本复制到我的 $HOME/bin
文件夹并从那里调用它时,我检查了 ~/.cache/uv/environments-v2/
,发现那里又创建了另一个 wordlookup-*
文件夹,其中包含虚拟环境。
在检查我的 Windows VM 时,我同样发现在 %LOCALAPPDATA%\uv\cache
下安装了 uv
虚拟环境。
经过进一步调查,我找到了一些 uv 缓存目录文档,描述了 uv 如何确定其缓存目录的位置。其工作原理如下:
uv 按以下顺序确定缓存目录:
- 如果请求了
--no-cache
,则为临时缓存目录。 - 通过
--cache-dir
、UV_CACHE_DIR
或tool.uv.cache-dir
指定的特定缓存目录。 - 系统适用的缓存目录,例如,在 Unix 上是
$XDG_CACHE_HOME/uv
或$HOME/.cache/uv
,在 Windows 上是%LOCALAPPDATA%\uv\cache
。
通常,在像我的 Fedora 这样的类 Unix 系统上,uv 将其缓存存储在 $HOME/.cache/uv
中。但是,您可以通过设置 $XDG_CACHE_HOME
环境变量来更改此位置。对于不熟悉 XDG 的人来说,XDG 基本目录规范是一套应用程序遵循以组织其文件的准则。它定义了几个指向特定目录的关键环境变量,确保不同类型的应用程序数据存储在其指定的位置。更多信息请参见这里。
总结来说,uv 将单文件 Python 脚本的虚拟环境存储在其缓存中,如果您不做任何特殊操作来更改默认设置,通常位于这些特定于操作系统的位置:
操作系统 | 虚拟环境位置 |
---|---|
Linux | ~/.cache/uv/environments-v2/ |
macOS | ~/.cache/uv/environments-v2/ |
Windows | %LOCALAPPDATA%\uv\cache\environments-v2 |
更新:Hacker News 上一位敏锐的用户 (sorenjan) 指出,您也可以运行 uv cache dir
来查找缓存目录的根路径(例如 ~/.cache
)。
uv 如何派生其虚拟环境文件夹名称?
看看我 Linux 系统上的以下 uv 虚拟环境文件夹。wordlookup-f6e73295bfd5f60b
这个文件夹名称是如何生成的?
~/.cache/uv/environments-v2/wordlookup-f6e73295bfd5f60b
(译者注:路径前加了 ~
使其更完整)
我对 uv 的 Rust 代码和其他资源的初步调查表明,虚拟环境文件夹名称是根据 Python 版本和外部包依赖版本(例如我上下文中的 httpx
)的哈希值生成的。这种设计确保了对这些元素的任何修改,包括脚本的名称(它本身嵌入在文件夹名称中),都会导致在缓存中创建一个唯一的虚拟环境。我通过观察发现,如果我在元数据中指定了不同版本的 httpx
,或者如果我更改了脚本文件的名称,uv 就会创建一个新的虚拟环境,从而凭经验验证了这一点。
结论
总而言之,uv 及其对 PEP 723 的实现是一个很棒的工具,它简化了我们处理带有外部依赖的单文件 Python 脚本的方式。通过将元数据直接嵌入脚本中,uv 消除了对单独的 requirements.txt
文件和复杂包管理器的需求。uv 简化了安装依赖项和管理虚拟环境的过程,使得运行这些脚本变得更加容易。shebang 和系统范围可执行文件的额外便利性进一步增强了可用性。最终,这种组合使得 Python 脚本编写更易于访问,特别是对于单文件脚本而言,并为开发者和用户都带来了更流畅的工作流程。
随附:
独立的带有uv的 Python 脚本
你可以在 Python 脚本的 shebang 行中添加 uv,使其成为一个自包含的可执行文件。
我正在做一个 Go 项目,以便更好地学习这门语言。这是一个由 postgres 数据库支持的简单 API。
当我需要测试一个端点时,我更喜欢在一个 ipython 交互式解释器中使用 httpx Python 包,而不是发出 curl 请求。能够检查响应并轻松地用字典打包有效载荷,而不是写出 JSON,这很不错。
总之,我决定编写一个脚本来更新或插入一些用户数据,以便我可以对我的/users端点进行测试。
我的jam_users.py脚本看起来像这样:
import httpx
import IPython
from loguru import logger
users = [
dict(name="The Dude", email="[email protected]", password="thedudeabides"),
dict(name="Walter Sobchak", email="[email protected]", password="vietnamvet"),
dict(name="Donnie", email="[email protected]", password="iamthewalrus"),
dict(name="Maude", email="[email protected]", password="goodmanandthorough"),
]
r = httpx.get("http://localhost:4000/v1/users")
r.raise_for_status()
for user in r.json()["users"]:
logger.info(f"Deleting: {user['name']}")
r = httpx.delete(f"http://localhost:4000/v1/users/{user['id']}")
r.raise_for_status()
for user in users:
r = httpx.post("http://localhost:4000/v1/users", json=user)
r.raise_for_status()
logger.info(f"Created: {r.json()}")
IPython.embed()
这真的很简单直接。它将清除任何现有的用户,然后插入这些测试用户。紧接着,我进入一个ipython交互式解释器来进行测试所需的操作。我所要做的就是运行:
python jam_users.py
然而,如果我想按原样运行脚本,我将需要选择这些方法之一:
在我的系统 Python 中全局安装依赖项httpx、IPython和loguru。
创建一个虚拟环境,激活它,安装依赖项,并在虚拟环境激活的状态下运行我的脚本。
在我看来,这两个都不是很好的选择。这些方法还依赖于安装一个与这些软件包兼容的系统 Python。这虽然不是一个大问题,但无论如何也是需要考虑的一点。
我最近经常使用 uv,并且越来越喜欢它作为包管理器的实用性、作为 pip 替代品的高效性以及用于独立 Python 可执行文件的能力。有一件事我还没有经常使用,那就是 Python 脚本中的特殊 # ///
当我第一次读到这个功能时,我非常怀疑。我不太喜欢在注释中嵌入语法。然而,这似乎是一个完美的应用。所以,我更新了我的脚本,将依赖项包含在脚本头中,如下所示:
# /// script
# dependencies = ["ipython", "httpx", "loguru"]
# ///
import httpx
import IPython
from loguru import logger
...
有了这个添加项,现在我可以用uv非常容易地运行这个脚本:
uv run jam_users.py
太好了!现在,uv将为脚本创建一个独立的虚拟环境,下载依赖项并进行安装,然后在该虚拟环境中运行我的脚本!我不必自己管理虚拟环境,也不必担心用那些我以后肯定会忘记删除的包把系统 Python 搞得乱七八糟。
不过,常规 Python 脚本的一个优点是,你可以使用 shebang 行使其可执行。
#!/usr/bin/env python
…
现在,如果我将脚本设置为可执行文件(chmod +x jam_users.py),我可以直接将其作为可执行脚本调用!然而,这不会利用 uv 脚本头的优势,因为 Python 本身会忽略该注释。
所以,我做了一些调查,发现实际上你可以将 uv 命令的调用直接嵌入到 shebang 行中,如下所示:
#!/usr/bin/env -S uv run --script
# /// script
# dependencies = ["ipython", "httpx", "loguru"]
# ///
import httpx
import IPython
from loguru import logger
...
这之所以有效,是因为-S标志告诉系统在将其后的所有内容拆分为单独的参数后,再将其传递给系统的env。
现在(当然是在“chmod +x jam_users.py”之后),我可以直接执行我的脚本:
./jam_users.py
就这样!更好的是,我可以在任何安装了uv的(Unix)系统上运行这个脚本,而无需进行任何依赖项或虚拟环境管理。
现在,这个脚本本身非常简单,只不过是一个玩具示例。然而,在过去,我写过相当复杂的脚本,需要交给其他用户运行。当然,这总是伴随着关于如何准备他们的系统以运行脚本的冗长解释。这种方法可以立即且无痛地解决那个问题(只要他们安装了 uv)。