内联脚本元数据

本规范定义了一种元数据格式,可嵌入到单文件 Python 脚本中,以帮助启动器、IDE 和其他可能需要与此类脚本交互的外部工具。

规范

本规范定义了一种元数据注释块格式(松散地受 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)+)^# ///$

在文本规范和正则表达式之间存在差异的情况下,文本规范优先。

工具不得读取类型未经过本规范标准化的元数据块。

脚本类型

第一种元数据块类型名为 script,其中包含脚本元数据(依赖数据和工具配置)。

本文档可以包含顶层字段 dependenciesrequires-python,并且可以可选地包含一个 [tool] 表。

[tool] 表可以被任何工具(脚本运行器或其他)用于配置行为。它具有与 pyproject.toml 中的 [tool] 表 相同的语义。

顶层字段是

  • dependencies:一个字符串列表,指定脚本的运行时依赖项。每个条目必须是有效的 依赖项说明符

  • 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)
        )

建议

支持管理不同 Python 版本的工具应尝试使用与脚本的 requires-python 元数据(如果已定义)兼容的最高可用 Python 版本。

历史

  • 2023 年 10 月:此规范通过 PEP 723 获得有条件批准。

  • 2024 年 1 月:通过对 PEP 723 的修订,pyproject 元数据块类型更名为 script,并删除了 [run] 表,使 dependenciesrequires-python 键成为顶层。此外,该规范不再是临时性的。