PEP 723 – 内联脚本元数据
- 作者:
- Ofek Lev <ofekmeister at gmail.com>
- 发起人:
- Adam Turner <adam at python.org>
- PEP 代理人:
- Brett Cannon <brett at python.org>
- 讨论至:
- Discourse 帖子
- 状态:
- 最终版
- 类型:
- 标准跟踪
- 主题:
- 打包
- 创建日期:
- 2023年8月4日
- 发布历史:
- 2023年8月4日, 2023年8月6日, 2023年8月23日, 2023年12月6日
- 取代:
- 722
- 决议:
- 2024年1月8日
摘要
本PEP规定了一种元数据格式,可嵌入到单文件Python脚本中,以帮助启动器、IDE和其他可能需要与此类脚本交互的外部工具。
动机
Python通常被用作脚本语言,Python脚本是shell脚本、批处理文件等的(更好)替代方案。当Python代码被构建为脚本时,它通常以单个文件存储,并且不期望任何其他可能用于导入的本地代码的可用性。因此,可以通过任意基于文本的方式与他人共享,例如电子邮件、脚本URL,甚至聊天窗口。以这种方式构建的代码可能永远以单个文件的形式存在,永远不会成为一个拥有自己的目录和pyproject.toml文件的成熟项目。
用户使用这种方法遇到的一个问题是,没有标准机制为执行此类脚本的工具定义元数据。例如,运行脚本的工具可能需要知道需要哪些依赖项或支持的Python版本。
目前没有标准工具可以解决这个问题,本PEP也不试图定义一个。但是,任何确实解决这个问题的工具都需要知道脚本的运行时要求。通过定义一种存储此类元数据的标准格式,现有工具以及任何未来的工具都将能够获取该信息,而无需用户在脚本中包含特定于工具的元数据。
基本原理
本PEP定义了一种将元数据嵌入到脚本本身内部的机制,而不是嵌入到外部文件中。
元数据格式旨在类似于Python项目目录的pyproject.toml文件中数据的布局,为有Python项目编写经验的用户提供熟悉的使用体验。通过使用类似的格式,我们避免了打包工具之间不必要的混乱,这是用户在最近的打包调查中普遍表达的挫败感。
以下是本PEP希望支持的一些用例:
- 一个面向用户的CLI,能够执行脚本。如果以Hatch为例,接口将简单地是
hatch run /path/to/script.py [args],Hatch将管理该脚本的环境。此类工具可在非Windows系统上用作shebang行,例如#!/usr/bin/env hatch run。 - 一个希望过渡到目录类型项目的脚本。用户可能正在本地或远程REPL环境中快速原型设计,如果他们的想法成功,然后决定过渡到更正式的项目布局。能够在脚本中定义依赖项对于完全可重现的错误报告将非常有用。
- 希望避免手动依赖项管理的用户。例如,具有添加/删除依赖项命令的包管理器,或基于新版本或响应CVE触发的CI中的依赖项更新自动化[1]。
规范
本PEP定义了一种元数据注释块格式,其灵感[2]来自reStructuredText指令。
任何Python脚本都可以有顶层注释块,必须以行# /// TYPE开头,其中TYPE决定如何处理内容。即:一个单独的#,后面跟着一个空格,后面跟着三个斜杠,后面跟着一个空格,最后是元数据的类型。块必须以行# ///结尾。即:一个单独的#,后面跟着一个空格,后面跟着三个斜杠。TYPE必须只包含ASCII字母、数字和连字符。
这两行(# /// TYPE和# ///)之间的每一行都必须是注释,以#开头。如果#后面有字符,则第一个字符必须是一个空格。嵌入内容是通过删除每行的前两个字符(如果第二个字符是空格)形成的,否则只删除第一个字符(这意味着该行只包含一个#)。
当下一行不是如上所述的有效嵌入内容行时,结束行# ///的优先级更高。例如,以下是一个完整的有效块:
# /// some-toml
# embedded-csharp = """
# /// <summary>
# /// text
# ///
# /// </summary>
# public class MyClass { }
# """
# ///
起始行不得放置在另一个起始行及其结束行之间。在这种情况下,工具可能会产生错误。未闭合的块必须被忽略。
当定义了多个相同TYPE的注释块时,工具必须产生错误。
读取嵌入式元数据的工具可以遵守标准Python编码声明。如果它们选择不这样做,它们必须将文件处理为UTF-8。
这是可用于解析元数据的规范正则表达式:
(?m)^# /// (?P<type>[a-zA-Z0-9-]+)$\s(?P<content>(^#(| .*)$\s)+)^# ///$
在文本规范和正则表达式存在差异的情况下,以文本规范为准。
工具不得读取类型未由本PEP或未来PEP标准化的元数据块。
脚本类型
第一种元数据块类型名为script,包含脚本元数据(依赖项数据和工具配置)。
本文档可以包含顶层字段dependencies和requires-python,并且可以可选地包含一个[tool]表。
[tool]表可被任何工具(脚本运行器或其他)用于配置行为。它与pyproject.toml中的工具表具有相同的语义。
顶层字段为
dependencies:一个字符串列表,指定脚本的运行时依赖项。每个条目必须是有效的PEP 508依赖项。requires-python:一个字符串,指定脚本兼容的Python版本。该字段的值必须是有效的版本说明符。
如果无法提供指定的dependencies,脚本运行器必须报错。如果无法提供满足指定的requires-python的Python版本,脚本运行器应该报错。
示例
以下是一个带有嵌入式元数据的脚本示例:
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "requests<3",
# "rich",
# ]
# ///
import requests
from rich.pretty import pprint
resp = requests.get("https://peps.pythonlang.cn/api/peps.json")
data = resp.json()
pprint([(k, v["title"]) for k, v in data.items()][:10])
参考实现
以下是如何在 Python 3.11 或更高版本上读取元数据的示例。
import re
import tomllib
REGEX = r'(?m)^# /// (?P<type>[a-zA-Z0-9-]+)$\s(?P<content>(^#(| .*)$\s)+)^# ///$'
def read(script: str) -> dict | None:
name = 'script'
matches = list(
filter(lambda m: m.group('type') == name, re.finditer(REGEX, script))
)
if len(matches) > 1:
raise ValueError(f'Multiple {name} blocks found')
elif len(matches) == 1:
content = ''.join(
line[2:] if line.startswith('# ') else line[1:]
for line in matches[0].group('content').splitlines(keepends=True)
)
return tomllib.loads(content)
else:
return None
工具通常会编辑依赖项,例如包管理器或CI中的依赖项更新自动化。以下是使用tomlkit 库修改内容的粗略示例。
import re
import tomlkit
REGEX = r'(?m)^# /// (?P<type>[a-zA-Z0-9-]+)$\s(?P<content>(^#(| .*)$\s)+)^# ///$'
def add(script: str, dependency: str) -> str:
match = re.search(REGEX, script)
content = ''.join(
line[2:] if line.startswith('# ') else line[1:]
for line in match.group('content').splitlines(keepends=True)
)
config = tomlkit.parse(content)
config['dependencies'].append(dependency)
new_content = ''.join(
f'# {line}' if line.strip() else f'#{line}'
for line in tomlkit.dumps(config).splitlines(keepends=True)
)
start, end = match.span('content')
return script[:start] + new_content + script[end:]
请注意,此示例使用了保留TOML格式的库。这绝不是编辑的要求,而是一个“锦上添花”的功能。
以下是如何读取任意元数据块流的示例。
import re
from typing import Iterator
REGEX = r'(?m)^# /// (?P<type>[a-zA-Z0-9-]+)$\s(?P<content>(^#(| .*)$\s)+)^# ///$'
def stream(script: str) -> Iterator[tuple[str, str]]:
for match in re.finditer(REGEX, script):
yield match.group('type'), ''.join(
line[2:] if line.startswith('# ') else line[1:]
for line in match.group('content').splitlines(keepends=True)
)
向后兼容性
在撰写本文时,# /// script块注释起始符未出现在任何Python文件在GitHub上。因此,现有脚本被本PEP破坏的风险很小。
安全隐患
如果使用自动安装依赖项的工具运行包含嵌入式元数据的脚本,则可能导致任意代码下载并安装到用户环境中。
这里的风险是用于运行脚本的工具功能的一部分,因此应该由工具本身解决。本PEP引入的唯一额外风险是,如果运行了带有嵌入式元数据的不受信任的脚本,则可能会安装潜在的恶意依赖项或传递依赖项。
这种风险通过运行代码前审查代码的良好实践来解决。此外,工具还可以提供锁定功能来减轻这种风险。
如何教授此内容
要在脚本中嵌入元数据,请定义一个注释块,该块以行# /// script开头,并以行# ///结尾。这两行之间的每一行都必须是注释,完整内容通过删除前两个字符来获取。
# /// script
# dependencies = [
# "requests<3",
# "rich",
# ]
# requires-python = ">=3.11"
# ///
允许的字段如下表所述:
| 字段 | 描述 | 工具行为 |
依赖项 |
一个字符串列表,指定脚本的运行时依赖项。每个条目必须是有效的PEP 508依赖项。 | 如果无法提供指定的依赖项,工具将报错。 |
requires-python |
一个字符串,指定脚本兼容的Python版本。该字段的值必须是有效的版本说明符。 | 如果无法执行满足约束的 Python 版本,工具可能会报错。 |
此外,还允许使用[tool]表。允许的内容细节与pyproject.toml中允许的内容相似,但精确信息必须包含在相关工具的文档中。
工具的行为是否根据嵌入式元数据而改变,取决于各个工具。例如,并非所有脚本运行器都能为requires-python字段定义的特定 Python 版本提供环境。
工具表可被任何工具(脚本运行器或其他)用于配置行为。
建议
支持管理不同Python版本的工具应尝试使用与脚本的requires-python元数据兼容的最高可用Python版本(如果已定义)。
工具的认可
以下是已表达对本PEP的支持或承诺在接受后实施支持的工具列表:
- Pantsbuild和Pex:表达了对任何定义依赖项方式的支持,以及本PEP认为是有效用例的功能,例如从脚本构建包和嵌入工具配置。
- Mypy和Ruff:强烈表达了对嵌入工具配置的支持,因为这将解决用户现有痛点。
- Hatch:(本PEP的作者)表达了对本PEP所有方面的支持,并将成为首批支持运行具有特定配置Python版本的脚本的工具之一。
被拒绝的想法
为什么不使用类似于requirements.txt的注释块?
本PEP认为Python代码将以单文件脚本形式存在的用户有不同类型:
- 非程序员,仅将Python用作脚本语言以完成特定任务。这些用户可能不熟悉操作系统概念,例如shebang行或
PATH环境变量。一些示例:- 普通人,也许在工作场所,想写一个脚本来自动化一些东西以提高效率或减少繁琐。
- 在工业或学术界从事数据科学或机器学习的人,他们想写一个脚本来分析一些数据或用于研究目的。这些用户的特殊之处在于,尽管他们编程知识有限,但他们从StackOverflow和具有编程倾向的博客等来源学习,并且越来越可能成为共享知识和代码社区的一部分。因此,相当一部分用户会熟悉Git(Hub)、Jupyter、HuggingFace等。
- 管理操作系统的非程序员,例如系统管理员。这些用户能够设置
PATH,但不太可能熟悉Python概念,例如虚拟环境。这些用户通常独立操作,并且对接触旨在共享的工具(例如Git)的需求有限。 - 管理操作系统/基础设施的程序员,例如SRE。这些用户不太可能熟悉Python概念(如虚拟环境),但可能熟悉Git,并且最常使用它来版本控制管理基础设施所需的一切,例如Python脚本和Kubernetes配置。
- 主要为自己编写脚本的程序员。这些用户随着时间的推移,积累了大量用各种语言编写的脚本,用于自动化他们的工作流程,并经常将它们存储在单个目录中,该目录可能经过版本控制以实现持久性。非Windows用户可能会为每个Python脚本设置一个shebang行,指向所需的Python可执行文件或脚本运行器。
本PEP认为,所提出的基于TOML的元数据格式对每类用户都是最佳选择,而类似于需求的块注释仅适用于熟悉requirements.txt的用户,这代表了用户的一小部分。
- 对于自动化任务的普通人或数据科学家来说,他们已经从零基础开始,不太可能熟悉TOML或
requirements.txt。这些用户很可能通过搜索引擎在线查找片段,或使用聊天机器人形式的AI或直接代码补全软件。pyproject.toml中存储的依赖项信息的相似性将相对快速地提供有用的搜索结果,尽管pyproject.toml格式和脚本元数据格式不尽相同,但由此产生的任何差异都不太可能难以解决。此外,这些用户最容易受到格式怪癖和语法错误的影响。TOML是一种定义明确的格式,具有现有的在线验证器,其赋值与Python表达式兼容,并且没有严格的缩进规则。另一方面,块注释格式很容易因忘记冒号等原因而格式错误,对于此类用户而言,使用搜索引擎调试为何不起作用将是一项艰巨的任务。
- 对于系统管理员类型,他们与之前描述的用户一样不太可能熟悉TOML或
requirements.txt。对于任何一种格式,他们都需要阅读文档。他们可能更适应TOML,因为他们习惯于结构化数据格式,并且他们的系统中感知到的魔法会更少。此外,为了维护其系统,从shell中搜索
/// script比搜索一个随着时间可能包含众多扩展的块注释要容易得多。 - 对于SRE类型,他们可能已经熟悉TOML,因为他们可能需要处理其他项目,例如配置GitLab Runner或云原生构建包。
这些用户负责其系统的安全性,并且很可能设置了安全扫描器,以自动打开PR来更新依赖项版本。像Dependabot这样的自动化工具使用现有TOML库会比为块注释格式编写自定义解析器容易得多。
- 对于程序员类型,除非他们是之前有编写应用程序经验的Python程序员,否则他们更可能熟悉TOML,而不是见过
requirements.txt文件。如果他们有使用requirements格式的经验,那必然意味着他们至少对生态系统有所熟悉,因此可以安全地假设他们知道TOML是什么。本PEP对这些用户的另一个好处是,他们的IDE(如Visual Studio Code)将更容易提供TOML语法高亮显示,而无需每个IDE都编写自定义逻辑来实现此功能。
此外,由于原始的块注释替代格式(双#)违反了PEP 8的建议,因此遵守该建议的linter和IDE自动格式化程序会默认失败,最终提案使用以单个#字符开头的标准注释,没有任何明显的开始或结束序列。
不像是为机器设计的常规注释(即编码声明)会影响行为的概念,对于Python用户来说并不常见,并且直接违反了“显式优于隐式”的基本原则。
用户输入对他们来说像是散文的内容,可能会改变运行时行为。本PEP认为,即使工具已进行如此设置(可能是由系统管理员设置),发生这种情况的可能性对用户来说也是不友好的。
最后,也是最关键的一点是,本PEP的替代方案,如PEP 722,不满足此处列举的用例,例如设置支持的Python版本、最终将脚本构建成包,以及机器代表用户编辑元数据的能力。很可能对这些功能的需求会持续存在,并且可以想象未来会有另一个PEP允许嵌入此类元数据。到那时,实现同一目标将有多种方式,这与我们“应该有一种——而且最好只有一种——显而易见的方法来做这件事”的基本原则相悖。
为什么不使用多行字符串?
本PEP的早期版本建议元数据应按以下方式存储:
__pyproject__ = """
...
"""
此提案最显著的问题是嵌入式TOML将受到以下限制:
- 无法在TOML中使用多行双引号字符串,因为这会与包含文档的Python字符串冲突。许多TOML写入器不保留样式,并可能生成格式错误输出。
- Python字符串中的字符转义方式与TOML字符串中的字符转义方式并不完全相同。可以通过强制使用原始字符串来保持一对一的字符映射,但这种
r前缀要求可能会让用户感到困惑。
为什么不重用核心元数据字段?
本PEP的早期版本建议重用现有的元数据标准,该标准用于描述项目。
此提案存在两个显著问题:
name和version字段是必需的,更改它们将需要单独的PEP。- 重用数据从根本上说是对它的误用。
为什么不限制为特定的元数据字段?
通过将元数据仅限于dependencies,我们将阻止工具支持管理Python安装的已知用例,而这可以允许用户针对新语法或标准库功能指定特定版本的Python。
为什么不限制工具配置?
通过不允许[tool]表,我们将阻止已知的功能,这些功能将造福用户。例如:
- 脚本运行器可能支持为嵌入式锁文件注入依赖项解析数据(Go 的
gorun可以做到这一点)。 - 脚本运行器可能支持配置,指示在容器中运行脚本,以应对依赖项没有跨平台支持或设置对普通用户来说过于复杂(例如需要 Nvidia 驱动程序)的情况。这种情况下,用户可以继续他们想做的事情,否则他们可能会完全停止。
- 工具可能希望尝试一些功能,以减轻用户的开发负担,例如将单文件脚本构建成包。我们收到了反馈,指出野外已经存在从单文件构建wheel和源分发的工具。
Rust RFC的元数据嵌入作者向我们提到,他们也正在积极研究这一点,因为用户反馈称管理小型项目存在不必要的摩擦。
至少有一个主要的构建系统已经承诺支持这一点。
为什么不限制工具行为?
本PEP的早期版本建议,当脚本不是工具的唯一输入时,非脚本运行工具不应修改其行为。例如,如果使用目录路径调用linter,则其行为应与零文件嵌入元数据时相同。
这样做是为了防止工具行为混乱,并避免为工具生成各种功能请求以支持本PEP。然而,在讨论中,我们收到了工具维护者的反馈,认为这将是不可取且可能对用户造成困惑的。此外,这可能允许在某些情况下以一种普遍更简单的方式配置工具并解决现有问题。
为什么不直接设置一个带有pyproject.toml的Python项目?
同样,这里的关键问题是,此提案的目标受众是编写不打算分发的脚本的人。有时脚本会被“共享”,但这比“分发”要非正式得多——通常涉及通过电子邮件发送脚本,并附带一些运行说明,或者将链接发送给GitHub Gist上的某人。
期望这些用户学习Python打包的复杂性是一个显著的复杂性提升,几乎肯定会给人留下“Python对于脚本来说太难了”的印象。
此外,如果这里的期望是pyproject.toml将以某种方式设计为原地运行脚本,那是标准的一项新功能,目前尚不存在。至少,在关于使用pyproject.toml作为不作为wheel分发的项目的当前讨论解决之前,这不是一个合理的建议。即使解决了,它也无法解决“通过Gist或电子邮件发送脚本给某人”的用例。
为什么不从导入语句推断依赖项?
这个想法是自动识别源文件中的import语句,并将它们转换为需求列表。
然而,这在几个方面是不可行的。首先,上面关于保持语法易于解析,适用于所有Python版本,也适用于用其他语言编写的工具的必要性,同样适用于这里。
其次,PyPI和其他符合简单仓库API的包仓库不提供从导入的模块名称解析包名称的机制(另请参见此相关讨论)。
第三,即使仓库提供了这些信息,同一个导入名称可能对应PyPI上的多个包。有人可能会反对说,只有当有多个项目提供相同的导入名称时才需要消除歧义以确定所需的包。然而,这将使任何人都可以通过上传一个提供与现有项目相同导入名称的包到PyPI来无意或恶意地破坏正在运行的脚本。另一种选择是,在候选包中,选择在索引上注册的第一个包,在流行的包开发出与现有晦涩包相同的导入名称时会造成混乱,甚至有害,如果现有包是恶意软件,特意上传了一个足够通用的导入名称,很有可能被重复使用。
一个相关的想法是将需求作为注释附加到导入语句中,而不是将它们收集在一个块中,其语法如下:
import numpy as np # requires: numpy
import rich # requires: rich
这仍然存在解析困难。此外,在多行导入的情况下,注释放置的位置是模棱两可的,并且可能看起来很丑:
from PyQt5.QtWidgets import (
QCheckBox, QComboBox, QDialog, QDialogButtonBox,
QGridLayout, QLabel, QSpinBox, QTextEdit
) # requires: PyQt5
此外,这种语法在所有情况下都不能像直观期望那样工作。考虑:
import platform
if platform.system() == "Windows":
import pywin32 # requires: pywin32
这里,用户的意图是只有在Windows上才需要这个包,但脚本运行器无法理解(正确的写法应该是requires: pywin32 ; sys_platform == 'win32')。
(感谢Jean Abou-Samra对这一点的清晰讨论)
为什么不使用依赖项的需求文件?
将你的需求放在需求文件中,不需要PEP。你现在就可以这样做,事实上,许多临时解决方案都这样做。然而,如果没有标准,就无法知道如何定位脚本的依赖项数据。此外,需求文件格式是pip特有的,因此依赖它的工具依赖于pip的实现细节。
因此,为了制定标准,需要两件事:
- 需求文件格式的标准化替代方案。
- 如何为给定脚本定位需求文件的标准。
第一项是一个艰巨的任务。它已经被多次讨论,但到目前为止还没有人真正尝试过。最可能的方法是为目前通过需求文件解决的单个用例开发标准。这里的一个选项是本PEP简单地定义一种新的文件格式,它只是一个文本文件,每行包含一个PEP 508需求。那就只剩下如何定位该文件的问题了。
这里“显而易见”的解决方案是做一些类似命名文件与脚本相同的事情,但带有.reqs扩展名(或类似的东西)。然而,这仍然需要两个文件,而目前只需要一个文件,因此不符合“更好的批处理文件”模型(shell脚本和批处理文件通常是自包含的)。它要求开发人员记住将这两个文件放在一起,这可能并非总是可行。例如,系统管理策略可能要求某个目录中的所有文件都是可执行的(例如,Linux文件系统标准对/usr/bin要求如此)。并且某些共享脚本的方法(例如,在Github的gist等文本文件共享服务上发布,或企业内部网)可能不允许从脚本位置推导关联的需求文件的位置(pipx等工具支持直接从URL运行脚本,因此“下载并解压脚本及其依赖项的zip文件”可能不是一个适当的要求)。
然而,本质上,这里的问题是有一个明确的声明要求,即该格式支持将依赖项数据存储在脚本文件本身中。不这样做只是忽略了该要求的解决方案。
为什么不使用(可能受限的)Python语法?
这通常涉及将元数据存储为多个特殊变量,如下所示。
__requires_python__ = ">=3.11"
__dependencies__ = [
"requests",
"click",
]
此提案最显著的问题是它要求所有依赖项数据的消费者实现一个Python解析器。即使语法受到限制,脚本的其余部分仍将使用完整的Python语法,并且试图定义一个可以成功地与周围代码隔离解析的语法可能极其困难且容易出错。
此外,Python的语法在每个版本中都会变化。如果提取依赖项数据需要一个Python解析器,那么解析器将需要知道脚本是用哪个Python版本编写的,并且对于通用工具来说,拥有一个可以处理多个Python版本的解析器的开销是不可持续的。
使用这种方法,随着新扩展的添加,脚本可能会被许多变量弄乱。此外,直观地判断哪个元数据字段对应哪个变量名会给用户造成困惑。
不过,值得注意的是,pip-run工具确实实现了这种方法(的扩展形式)。有关pip-run设计的进一步讨论可在该项目的issue跟踪器上找到。
本地依赖项怎么办?
这些可以不需特殊元数据和工具即可处理,只需将依赖项的位置添加到sys.path即可。本PEP在这种情况下根本不需要。另一方面,如果“本地依赖项”是本地发布的实际发行版,它们可以像往常一样用PEP 508要求指定,并在运行工具时使用工具的UI指定本地包索引。
未解决的问题
目前没有。
脚注
版权
本文档置于公共领域或 CC0-1.0-Universal 许可证下,以更宽松者为准。
来源: https://github.com/python/peps/blob/main/peps/pep-0723.rst
最后修改: 2025-08-08 15:00:59 GMT