使用 uv 和 PEP 723 构建自包含的 Python 脚本

像专业人士一样分享 Python 脚本:使用 uv 和 PEP 723 实现轻松部署

2025 年 3 月 25 日 · 13 分钟 · Image of Dave Johnson Dave Johnson Python Uv Windows Linux MacOS

摘要 借助 uv 和 PEP 723,现在可以轻松共享具有外部依赖项的单文件 Python 脚本,它们能够将依赖项元数据直接嵌入到脚本中。 这种方法消除了对复杂设置工具(如 requirements.txt 或包管理器)的需求,从而使脚本分发和执行无缝衔接,并在保持灵活性和效率的同时简化了部署。 目录

我们都喜欢 Python 丰富的标准库,但让我们面对现实吧——PyPI 中大量的软件包通常变得必不可少。 共享依赖于这些外部工具的单文件、自包含的 Python 脚本可能令人头疼。 历史上,我们依赖于 requirements.txt 或完善的包管理器,例如 Poetry 或 pipenv,对于简单的脚本来说,这可能有些过头,并且令新手望而却步。 但是,如果有一种更简单的方法呢? 这就是 uv 和 PEP 723 的用武之地。本文深入探讨了 uv 如何利用 PEP 723 将依赖项直接嵌入到脚本中,从而使分发和执行极其容易。

uv 和 PEP 723#

uv及其下一代 Python 工具的一个我最喜欢的功能是,能够运行包含对外部 Python 包的引用的单文件 Python 脚本,而无需太多仪式。 uv 通过 PEP 723 来实现这一壮举,该提案侧重于“内联脚本元数据”。 此 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:
      terminal_width = os.get_terminal_size().columns - 4 # 4 for padding
    except OSError:
      terminal_width = 80 # default if terminal size can't be determined
    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))

Copy 此脚本导入了多个 Python 模块,为一个与字典 API Web 服务交互、处理 JSON 数据、处理命令行参数、利用异步操作、格式化文本输出以及与操作系统交互以获取终端宽度的脚本做好了准备。 除了 HTTP 客户端库包 httpx 之外,我们导入的所有其他 Python 模块都是 Python 标准库的一部分。 虽然我可以通过 Python 的内置 urllib.request 模块来完成此目标,但我更喜欢 httpx。 但是,这提出了一个难题,因为我需要一种好的方法来分发此脚本,以便我的朋友和同事可以使用它,而无需费力地安装所需的 httpx 依赖项。 我们如何解决这个难题? uv 来救援! 接下来,我们将逐步介绍其工作原理。

安装 uv#

第一步,我们首先需要安装 uv。 有关安装 uv 的指导,请参阅官方 uv 文档 installing uv。 安装 uv 的几种常见方法包括:

# Assuming you have pipx installed, this is the recommended way since it installs
# uv into an isolated environment
pipx install uv
# uv can also be installed this way
pip install uv

Copy uv 是一个非常通用的工具,在我看来,它在很大程度上代表了 Python 工具的未来。 但是,在本文中,我仅演示 uv 用于调用具有外部依赖项的单文件脚本的一个很棒的功能。

使用 uv 在单文件脚本中添加包依赖项#

我们现在准备在我们的 wordlookup.py 脚本中添加 httpx 作为依赖项! 做法如下:

uv add --script wordlookup.py httpx

Copy 就是这样! 在此之后,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

Copy 如果您已将 pyproject.toml 与各种 Python 工具(例如 Poetry、Flit、Hatch、Maturin、setuptools 等)一起使用,则此语法可能看起来至少有些熟悉。 例如,Poetry 可能如下所示:

# <-- other package metadata here -->
[tool.poetry.dependencies]
python = ">=3.13"
httpx = "^0.28.1"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

Copy 您会观察到 uv 添加了 httpx 的元数据,但未指定版本。 uv 将从 PyPI 中获取 httpx 的最新稳定版本以供脚本使用。 您可以通过在事后直接修改元数据或通过命令行指定版本依赖项来添加依赖项约束:

uv add --script wordlookup.py "httpx>=0.28.1"

Copy

使用 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

Copy 首次使用 uv run 调用脚本时,您会看到开头有一些额外的活动,因为 uv 会在后台自动创建一个隔离的虚拟环境,并获取并安装 httpx 包及其关联的依赖项。 这就是为什么我们在终端输出中看到 Installed 7 packages in 74ms。 如果您尝试使用 python wordlookup.py 运行脚本,则脚本将失败,除非您恰好全局安装了 httpx 或在当前虚拟环境中安装了 httpx。 uv 如何使用脚本元数据? 使用 uv run 调用脚本时,uv:

对于后续每次使用 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.

Copy 如果我们向脚本添加其他依赖项或更改元数据中的 Python 或 httpx 版本,则 uv run 会在下次调用时创建一个新的隔离虚拟环境。

使用 Python Shebang 使运行更加容易#

我们可以在 Python 脚本的顶部添加一个 shebang(有时称为 hashbang),以使使用 uv 调用脚本更加容易。 我从 Trey Hunner here 学到了这个很棒的技巧。

Linux/macOS 用户#

对于 Linux 和 macOS(以及 BSD 用户),请在脚本顶部添加以下行:

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

Copy 更完整的脚本上下文在文件顶部如下所示:

#!/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

Copy 接下来,使文件可执行:

chmod u+x wordlookup.py

Copy 完成后,您可以直接运行脚本,而无需使用完整的 uv run wordlookup.py 命令:

./wordlookup --help

Copy

Windows 用户#

对于 Windows 用户,您也很幸运,因为 Windows 的 py 启动器也能够解释 shebang。 安装 Windows 上的 Python 时,默认情况下会包含 py 启动器。 请注意,您需要从 shebang 中省略 -S,脚本才能正常工作。 脚本的第一行应如下所示:

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

Copy 然后,您可以使用 py 命令在 Windows 上按如下方式调用脚本:

py wordlookup.py

Copy

注意:如果您通过 python wordlookup.py 调用脚本,这将不起作用,因为不会解释 shebang。

设置 uv 脚本以便从计算机上的任何位置调用#

为了使您的 uv(Python)脚本可以从系统上的任何位置轻松执行,您可以将其移动到系统 PATH 中包含的常见可执行目录。

Linux/macOS 用户#

对于 Linux 和 macOS 用户,请将 wordlookup.py 脚本复制到系统 $PATH 中的目录。 在我的系统上,$HOME/bin 文件夹位于路径中,我将其移动到那里:

mv wordlookup.py ~/bin

Copy 我还选择重命名该文件并删除 .py 文件扩展名,以使其调用起来更符合人体工程学,因为 shebang 包含标识该文件为 Python 脚本所需的所有信息:

mv wordlookup.py wordlookup

Copy 我现在能够从任何地方调用它。 (您还会观察到,uv 将创建一个新的虚拟环境并解析软件包依赖项,这是第一次从新位置调用 Python 脚本。)

$ 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

Copy

Windows 用户#

对于 Windows 用户,您可以将脚本移动到已包含在系统 PATH 环境变量中的某个目录,或者向 PATH 添加一个新文件夹。 我将假定您已创建名为 c:\scripts 的文件夹并将其添加到您的 PATH。 接下来,创建一个名为 wordlookup.cmd 的文件并添加以下内容:

@echo off
py c:\scripts\wordlookup.py %*

Copy 然后,您将能够从系统上的任何位置(如 Windows 终端或命令提示符)调用该脚本,如下所示:

wordlookup --help

Copy

奖励:uv 在哪里安装其虚拟环境?#

作为一名好奇的软件工程师,我决定深入研究一下,看看是否可以发现在我的 Fedora Linux 系统上,uv 在哪里安装其虚拟环境。 毕竟,wordlookup.py 位于其自己的专用目录中。 在运行 uv add --script 以添加 httpx 包依赖项元数据并调用 uv run 后,在本地文件夹中看不到任何虚拟环境目录(如 .venv)。 我首先在我的系统上找到所有名为 httpx 的目录,因为在创建脚本后第一次调用 uv run 时,可能会创建一个具有此名称的新文件夹。

$ find -type d -name httpx
./.cache/uv/environments-v2/wordlookup-f6e73295bfd5f60b/lib/python3.13/site-packages/httpx
# <other folders found but omitted for brevity>

Copy 看哪,我在一个名为 ./.cache/uv/environments-v2 的父文件夹中找到了一个名为 httpx 的文件夹。 这看起来很有希望。 然后,我发现了一个可以运行的命令 (uv cache clean),以清除所有 uv 虚拟环境。 这些都是无害的,因为可以轻松地重新创建虚拟环境。

$ uv cache clean
Clearing cache at: .cache/uv
Removed 848 files (8.2MiB)

Copy 为了在我的 Linux 系统上观看所有操作(也许这有点过头了 😃),我使用了 inotifywait 来监视当我调用 uv run wordlookup.py 时将发生的所有文件创建事件,因为在我清除缓存后,uv 将需要重新创建其虚拟环境。

inotifywait -m -r -e create ~/.cache/
# While this was running and waiting for event, I invoked `uv run wordlookup.py` from another terminal window

Copy inotifywait 命令(inotify-tools 包的一部分)等待文件系统事件并输出它们。 这是我使用的参数:

可以肯定的是,inotifywait 显示了在启动 uv run wordlookup.py 时动态创建的文件夹。 当我将 wordlookup.py 脚本复制到我的 $HOME/bin 文件夹并从那里调用它时,我检查了 ./.cache/uv/environments-v2/,并且在那里创建了另一个 wordlookup-*,其中包含虚拟环境。 在查看我的 Windows VM 时,我类似地发现 uv 虚拟环境安装在 %LOCALAPPDATA%\uv\cache 下。 经过进一步调查,我找到了一些 uv 缓存目录文档,该文档描述了 uv 如何确定其缓存目录的位置。 其工作方式如下:

uv 根据以下顺序确定缓存目录:

  • 如果请求了 --no-cache,则为临时缓存目录。
  • 通过 --cache-dirUV_CACHE_DIRtool.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 基本目录规范是应用程序遵循的一组指南,用于组织其文件。 它定义了一些关键环境变量,这些变量指向特定目录,从而确保不同类型的应用程序数据存储在其指定的位置。 有关更多信息,请参阅 here。 总而言之,uv 将单文件 Python 脚本的虚拟环境存储在其缓存中,如果您不执行任何特殊操作来更改默认值,则通常位于这些特定于操作系统的位置: 操作系统| 虚拟环境位置 ---|--- Linux| ~/.cache/uv/environments-v2/ macOS| ~/.cache/uv/environments-v2/ Windows| %LOCALAPPDATA%\uv\cache\environments-v2

uv 如何导出其虚拟环境文件夹名称?#

请看一下我 Linux 系统上的以下 uv 虚拟环境文件夹。 如何生成 wordlookup-f6e73295bfd5f60b 的文件夹名称?

./.cache/uv/environments-v2/wordlookup-f6e73295bfd5f60b

Copy 我对 uv 的 Rust 代码和其他资源的初步调查表明,虚拟环境文件夹名称是从 Python 版本和外部包依赖项版本(例如我的上下文中的 httpx)的哈希生成的。 此设计确保对这些元素的任何修改,包括脚本的名称(该名称嵌入在文件夹名称本身中),都会在缓存中创建一个唯一的虚拟环境。 我通过经验验证了这一点,观察到如果我在元数据中指定了不同版本的 httpx 或如果我更改了脚本文件的名称,uv 会创建一个新的虚拟环境。

结论#

总而言之,uv 及其 PEP 723 实现是一个很棒的工具,可以简化我们处理具有外部依赖项的单文件 Python 脚本的方式。 通过将元数据直接嵌入到脚本中,uv 消除了对单独的 requirements.txt 文件和复杂包管理器的需求。 uv 简化了安装依赖项和管理虚拟环境的过程,从而使运行这些脚本变得更加容易。 shebang 和系统范围可执行文件的附加便利性进一步增强了可用性。 最终,这种组合使 Python 脚本编写更易于访问,特别是对于单文件脚本,并有望为开发人员和用户提供更简化的工作流程。

分享这篇文章

在 X(以前的 Twitter)上分享在 Linkedin 上分享在 Reddit 上分享在 Facebook 上分享在 Mastodon 上分享在 Bluesky 上分享[在 WhatsApp 上分享](https://thisdavej.com/share-python-scripts-