An unofficial translation of PEP 691.
-
翻译日期:
2024-08-10
-
免责声明:此翻译版本仅用于个人研究学习用途,不具有 PEP 协议的权威性。本译文不对翻译的正确性负责,也不对相关衍生作品内容的正确性负责。
-
Original URL:https://peps.python.org/pep-0691/
-
Translation Date:
2024-08-09
-
Disclaimer: This translation is only for personal study purposes and is not authorized by PEP administration. This translation is not responsible for the incorrectness of the relevant facts, nor is it responsible for the incorrectness of the content of related derivative works.
在 PEP 503 中规定的 “简单代码库 API“ 这一概念已经成为 Python 的基础设施很长一段时间了(甚至早在 PEP 503 前这一概念就已经广为人知)。然而,依赖 HTML 作为数据交换的机制有一些不尽如人意之处。
基于 HTML 的 API 主要有以下两个问题:
-
尽管 HTML5 确实有成文规范,但它过于复杂,需要十分繁琐的逻辑才能完全正确地将其解析,且当下 Python 标准库中并不存在能完全正确解析 HTML5 的库(不只是 Python,很多其他语言中也没有这样的标准库)。
这意味着,理论上我们能够正确地解析 HTML,但我们不得不进行如下的抉择:要么引入大规模的依赖以保证 HTML 的正确解析,要么使用 Python 标准库中提供的轻量级解析库
html.parser
,但这一解析库可能无法为 HTML5 提供完整的支持。 -
HTML5 本质上是一门为了人类可读而设计的标记性语言。我们之所以会使用它,主要是出于历史性以及偶然性的因素,倘若当下让我们从零开始重新设计 Python 代码库的 API,恐怕没人会设计一个基于 HTML5 的协议。
使用人类可读的标记性语言的主要问题在于,并不存在将数据编码为 HTML 的完美方式。为了解决这一问题,我们限制在我们向 API 中填入的数据,然后又发挥我们的创造力将各种额外的数据塞进 API 里(例如,哈希值被以 URL 片段的形式塞入 API,,PEP 592 中又添加了
data-yanked
属性)。
PEP 503 在很大程度上试图对现行的 API 进行标准化,因此它未对 API 进行任何大规模地修改。
在过去的几年里,我们经常谈论一个关于 “API V2” 的设想,在这一设想中我们重新规划了 PyPI 的全部 API。然而,由于时间有限,相关努力成果不多,尽管从整体来看新 API 的构想颇有裨益。
本 PEP 打算尝试另一条路线。本文并不企图彻底改变现有的 API 结构,相反,我们要提出一种新的针对 PEP 503 中相响应数据序列化的方法,这种序列化方法将使用利于软件解读的格式而不是便于人类阅读的格式。
-
实现无需配置的发现。简单 API 的客户端**必须(MUST)能够优雅地判断目标代码库是否不需要任何带外通信(out of band communication)就能支持本 PEP 中的 API 协议(这里的带外通信包括但不限于:配置、先前的知识等等)。但客户端可以(MAY)**选择通过配置决定是否采用本协议中的 API。
-
使客户端可以不再支持过时的 HTML 解析。尽管在接下来很长的一段时间内,仍会有很多客户端继续支持仅实现了 HTML-API 的 PyPI 代码库。在有限长的时间后,客户端应该能够丢弃过时的 HTML 解析格式,转而仅使用新的 API 解析格式。
-
使代码库可以不再支持过时的 HTML 格式。与客户端类似,可以预期到,未来一段时间内,大多代码仓库将会继续支持 HTML 格式的响应(甚至可能会永远支持下去)。我们寄希望于,在有限长的时间后,代码库将可以选择只支持新的 API 格式。
-
对现有的仅支持 HTML 格式 API 的客户端提供完整支持。我们**必须(MUST)**严格按照 PEP 503 中的 API 约定为那些现存的客户端提供正确的服务。除非代码库本身决定不再支持 HTML 格式的响应。
-
尽可能减少额外的 HTTP 请求。为了让本文 API 起作用,我们**必须(MUST)**保证使用本文 API 不会大幅度增加必要的 HTTP 请求的数量。最理想的情况下,应该能做到不依赖任何额外的 HTTP 请求,必要时可以使用一到两个额外的 HTTP 请求(总计增加一到两个 HTTP 请求,而不是每个依赖项的下载增加一到两个 HTTP 请求)。
-
尽可能减少独一无二的响应内容。由于像 PyPI 这样的大代码库往往会对响应进行缓存,因此本 PEP 不应该引入有大量组合可能的不同响应。
-
支持 TUF。本 PEP 必须(MUST)能在 TUF 支持的界限下正常工作(详见 PEP 458),且必须能利用 TUF 实现安全性。
-
客户端需要仅依赖标准库以及尽可能少的外部依赖。接触 API 内容最理想的情况下应该只依赖 Python 的标准库,依赖少量的纯 Python 编写的额外依赖也未尝不可。
为了仅使用标准库即可实现响应解析,此 PEP 指定所有响应(除了文件本身和来自 PEP 503 的 HTML 响应)都应使用 JSON 进行序列化。
为了实现零配置发现并尽量减少额外的 HTTP 请求量,本 PEP 扩展了 PEP 503,使得所有 API 端点(文件传输除外)都将利用 HTTP 内容协商来允许客户端和服务器选择正确的序列化格式来提供服务,即 HTML 或 JSON。
我们将继续采用 PEP 629 中提出的版本控制格式(大版本号.小版本号
),现有的 HTML 响应将被标记为版本 1.0
。因为本 PEP 既没有向 API 中引入新的特性,也没有为现有的特性引入新的序列化格式,因此本 PEP 并没有改变版本号(即 1.0
)而只是描述如何将响应数据序列化为 JSON。
与 PEP 629 类似,如果新格式的引入会导致现有的客户端不再能够正确理解响应的内容,则大版本号**必须(MUST)**至少增一。
同理,当我们在格式中增加或删除特性时,若现有客户端仍然能够正确理解响应的内容时,小版本号**必须(MUST)**至少增一。
对修改而言,引入或删除特性但不影响客户端正常解析的,或者修改本身并不修改特性的,版本号可以保持不变。
我们故意在有关版本号的表述中含糊其辞,因为本 PEP 认为,是否增加大版本号或小版本号应当由未来对 API 进行修改的 PEP 决定。
未来版本的 API 可能会使用当前版本 API 可用的所有序列化器中的一个子集。同一个大版本号下的所有序列化器版本号**应该(SHOULD)**保持同步,但关于一个特性如何被序列化的具体描述在不同版本间可能不同(包括但不限于某个特性本身是否存在)。
本 PEP 的意图是,API 应当被视为带有数据返回的 URL 端点,而数据的解释取决于版本号,且数据将会被序列化为客户端指定的格式。
PEP 503 中提出的 URL 结构仍然适用,本文只是在此基础上对现有的 URL 结构增加了一种额外的序列化方式。
下述限制适用于本 PEP 中提及的所有 JSON 序列化过程:
- 所有的 JSON 响应*总是(alway)*一个 JSON 对象,而不是 JSON 数组或者其他类型;
- 尽管 JSON 并不原生支持 URL,JSON 数据中任何表示 URL 的信息要么采用绝对路径表示,要么采用相对与当前 URL 的相对路径表示;
- JSON 中的字典对象中可以引入额外的键,但客户端**必须(MUST)**忽略他们不能理解的键;
- 所有的 JSON 响应都会有一个
meta
键,其中包含响应的相关信息,而不是响应的具体内容; - 所有的 JSON 响应都包含一个
meta.api-version
键,其中包含一个字符串,该字符串以 PEP 629 中规定的大版本号.小版本号
的格式给出当前协议的版本号,出错或警告的语义也与 PEP 629 相同。 - PEP 503 中除 HTML 外的需求在本文中依然适用;
提及根 URL /
(代表代码库的基地址 URL)对应的响应是一个具有两个键
的 JSON 编码的字典:
projects
:一个以字典为元素的数组,每个字典包含一个name
键,表示一个项目的名字;meta
:前文中所描述的响应信息的元信息;
下面是一个例子:
{
"meta": {
"api-version": "1.0"
},
"projects": [
{"name": "Frob"},
{"name": "spamspamspam"}
]
}
注意
本文对上述
name
字段值的要求与 PEP 503 一致,在此我们并不具体要求显示的名字是否是规范名。实践中,对这些 PEP 的不同实现在此处往往采用了不同的实现,因此对此处名字形式的依赖本质上是对不同实现的依赖。
注意
由于
projects
字段的值是一个数组,因此此处需要指明数组中元素按照何种顺序排序,但在 PEP 503 和本文中,我们既没有指明此处应该采用何种顺序,也不保证在不同次请求中给出的顺序具有一致性。主观上,我们最好将这个数组视为一个集合(set),换言之其中不应有重复的元素,但无论是 HTML 还是 JSON 都没有提供描述集合的功能。
项目详情的 URL 格式为 /<project>/
,其中 <project>
将被替换为 PEP 503 中约定的项目的规范名,因此一个名为 “Silly_Walk” 的项目的 URL 将是 /silly-walk/
。
这个 URL 返回的响应必须是一个带有以下三个键的 JSON 字典对象:
name
:项目的规范名称;files
:一个以字典为元素的列表,每个字典对应一个文件;meta
:前文所述的响应元信息;
每个文件对应的字段应该有以下几个键:
-
filename
:文件的实际文件名; -
url
:下载这个文件所需访问的 URL 路径; -
hashes
:一个字典,以哈希算法名为键,以十六进制表示的哈希值为值,构成从哈希算法名到哈希值的映射。可以给出多种算法计算得到的哈希值,在这种情况下的和细致校验将取决于客户端的具体实现(客户端既可以验证验证他们中的一个子集,也可以全部进行了验证,也可以完全不验证)。这些哈希算法名**应当(SHOULD)**使用全小写的规范名;即使当服务端并不知道该文件的任何哈希值时,服务端也**必须(MUST)**提供
hashes
这一字段(提供一个空字典),然而我们强烈建议(HIGHLY recommended)服务端应至少提供一种用于安全保障的可用哈希;默认地,任何由 hashlib 库 提供的哈希算法都可以用作
hashes
字典的键(具体来讲,这些算法可以不带任何附加参数地传递给函数haslib.new()
)。服务端至少**应当(SHOULD)**引入一个在hashlib.algorithms_guaranteed
中列出的安全哈希算法。在本文中,我们尤其推荐使用sha256
算法; -
requires-python
:一个可选键,该键以 PEP 345 中描述的格式定义了 Requires-Python 元数据。当给出这一字段时,客户端下载器**应该(SHOULD)**忽略那些 Python 版本不满足要求的需求项;与 PEP 503 中不同,JSON 数据中的
requires-python
字段值不需要进行 HTML 转义,只需要使用 JSON 直接存储相应字符串数据即可; -
dist-info-meta
:一个可选键,表明此文件的元数据可用,通过 PEP 658 中的位置 ({文件 URL}.metadata
)可以获取相应元数据。当这个键存在时,其值**必须(MUST)**要么是一个布尔类型的值以表示元信息是否存在,要么是一个字典类型值用于表示从哈希算法名到十六进制哈希摘要的映射;当该字段是一个字典而不是一个布尔值时,对其值的要求与对
hashes
字段的要求完全一致;如果相应信息中不包含这一字段,元信息文件可能存在,也可能不存在。当该字段值为
true
或者字典时,元信息文件一定存在,反之当该字段值为false
时元信息文件一定不存在。建议服务端尽可能提供元信息的哈希值。
-
gpg-sig
:一个可选键,具有布尔类型值,用于表示该文件是否有与之关联的 GPG 签名文件。如果签名存在,根据 PEP 503,该签名文件将位于{文件 URL}.asc
处。如果该字段不存在,则签名文件可能存在,也可能不存在; -
yanked
:一个可选键,可以使用一个布尔值表示该项目是否是不建议使用的(ranked),也可以是一个非空字符串用于表示该项目不建议被使用的具体原因。如果该字段为真值,则其应该被客户端依照 PEP 592进行解释,即当前 URL 指向的文件是不建议使用的(Yanked)。
下面是一个例子:
{
"meta": {
"api-version": "1.0"
},
"name": "holygrail",
"files": [
{
"filename": "holygrail-1.0.tar.gz",
"url": "https://example.com/files/holygrail-1.0.tar.gz",
"hashes": {"sha256": "...", "blake2b": "..."},
"requires-python": ">=3.7",
"yanked": "Had a vulnerability"
},
{
"filename": "holygrail-1.0-py3-none-any.whl",
"url": "https://example.com/files/holygrail-1.0-py3-none-any.whl",
"hashes": {"sha256": "...", "blake2b": "..."},
"requires-python": ">=3.7",
"dist-info-metadata": true
}
]
}
注意
由于
files
键中给出了一个列表,因此其中元素需要遵照某种顺序进行排序,但 PEP 503 和本 PEP 据没有给出关于这一顺序的任何保证,同时也并不保证在不同次询问时得到的顺序是否相同。主观上,我们最好将这个数组视为一个集合(set),换言之其中不应有重复的元素,但无论是 HTML 还是 JSON 都没有提供描述集合的功能。
本 PEP 提议,所有来自简单 API 服务的响应都应该具有规范化的内容类型名,该类型名中描述了响应是什么、采用了何种版本以及使用了何种序列化方法。
内容类型名的结构如下:
application/vnd.pypi.simple.$版本+格式
也就意味着对现存的 1.0 API ,内容类型名称将会是:
- JSON:
application/vnd.pypi.simple.v1+json
- HTML:
application/vnd.pypi.simple.v1+html
除此之外,对客户端而言,它们还可以采用名为 latest
的版本名称,从而可以在不了解服务端能提供何种版本的响应的前提下要求服务端直接提供它能提供的最新版本。然而,我们仍然建议客户端在内容类型名中填写自己能支持的版本而不是直接使用 latest
。
为了支持现存的依照 PEP 503 API 工作的使用 text/html
内容类型的客户端,本 PEP 进一步规定,text/html
是 application/vnd.pypi.simple.v1+html
响应类型的别名。
由于我们提供了多种可能的序列化方式,我们需要一种机制使得客户端可以推测何种序列化格式是自己所能理解并处理的。另外,当我们开发新的 API 大版本时,我们十分希望它能不干扰基于前一大版本开发的客户端的正常工作。
为了实现这一点,本 PEP 使用了 HTTP 协议中的 服务端驱动的类型协商功能。
本文并不会给出服务端类型协商功能的完整过程,下面是一个类型协商的大致流程:
- 客户端需要早 HTTP 请求头中添加一个
Accept
字段,并在其中列举所有它能够处理的内容类型(包含版本号和序列化格式); - 服务端读取来自客户端的请求头,选择其中一个类型,并返回指定类型的内容(如果获服务端读取到的请求头中缺失
Accept
字段,则理解为客户端能够接收任何响应类型,即Accept: */*
); - 如果服务端不支持客户端请求头中列举的任何内容类型,服务端有三种可供选择的响应方式:
- 选择自身默认使用的内容类型,而不是客户端指定的内容类型;
- 返回一个 HTTP
406 Not Acceptable
响应以表明自己不能接收客户端指定的响应类型,且服务端不能或不愿意给出一个自身默认使用的内容类型的响应; - (不建议)返回一个 HTTP
300 Multiple Choices
响应,响应中包含客户端可以选择的所有响应类型;
- 客户端解释服务端给出的响应,根据来自服务端的不同响应分析应当采取的行为。
在上述第三步中,本文提出了面临无法满足的客户端类型需求时服务端可以采取的三种处理策略,但本 PEP 并不强制要求服务端采取其中具体哪一种。客户端应当做好处理不同响应的准备以尽可能合理地与服务端进行交互。
然而,由于标准格式中并没有指明 300 Multiple Choices
响应应该被如何解释,因此本 PEP 强烈不建议使用该响应码响应服务端不能接受的内容类型需求,因为客户端并不知道自己应该如何从服务端的响应中选择一个不同的响应类型以重发请求。另外,往往客户端也没有理解其他内容类型的能力,因此客户端处理 300 Multiple Choices
响应时,最后应该将其当作 406 Not Acceptable
错误一样处理。
本 PEP 要求,当客户端使用了 latest
版本的内容格式进行请求时,服务端在响应时,仍然应该标注出响应数据的实际类型(例如,如果客户端请求了 Accept: application/vnd.pypi.simple.latest+json
类型数据,当下即表示服务端应当返回一个 v1.x
类型的响应,因此服务端响应的内容类型应该是 application/vnd.pypi.simple.v1+json
)。
请求头的 Accept
字段中应该包含一个逗号分隔值,其中包含了本客户端能够理解并处理的所有内容类型。其中每个可供选择的内容类型可以采用下述三种格式:
$类型/$子类型
$类型/*
*/*
由于我们使用这一功能主要是为了选择版本号与类型,因此其中最常用的格式为 $类型/$子类型
,因为只有这种格式才能表达出客户端想要的版本信息与格式信息。
当客户端请求头的 Accept
字段中列举了多种不同内容类型时,内容类型而顺序并没有特殊含义,服务端**应当(SHOULD)**平等地考虑它们并从中选择合适的类型以进行响应。如果客户端希望指明自己对内容类型的偏好时,它们可以使用 Accept
请求头的 qulity value 语法。
客户端能够表达出自己对 Accept
中列举的所有词条的偏好程度,它们需要在指定的词条后追加一个 ;q=
开头的后缀,其后紧跟一个 0 到 1 闭区间内的十进制小数(至多包含 3 个十进制位)。值越大,说明客户端对该类型的响应质量越高,,服务端应当优先选择客户端响应质量较高的类型。没有给出响应质量的词条,其响应质量将被默认视为 1
。
但客户端应当了解到,尽管自己给出了对不同内容类型的偏好程度,服务端仍有从内容列;序列中选择任何一个的自由,无论它们的响应质量等级被孰高孰低。甚至,服务端也有可能采用一种客户端并没有提供的内容类型给出响应。
为了协助客户端确定拿到的响应的具体类型,本 PEP 要求服务端在所有响应的响应头中必须启用 Content-Type
字段以标出实际的响应类型。这在技术上是一个无法做到向后兼容(backwards incompatible)的变更,然而在实践中 pip 已经采取了强制要求,因此在实际上出现问题的概率很低。
下面给出了一个客户端可能采用的工作方式的例子:
import email.message
import requests
def parse_content_type(header: str) -> str:
m = email.message.Message()
m["content-type"] = header
return m.get_content_type()
# 构建可接受的内容类型列表
# 我们更希望拿到一个 v1 版本的 JSON 响应
# 但同时我们也可以支持 v1 版本的 HTML 响应
# 然而同时我们还支持过时的 text/html 类型的响应
# 但由于我们并不确定 text/html 类型的响应是否服从了简单 API 响应协议
# (因为这个 text/html 类型的响应很可能只是一个错误配置导致的随机 HTML 页面)
# 因此我们最不希望接受这种类型的响应
CONTENT_TYPES = [
"application/vnd.pypi.simple.v1+json",
"application/vnd.pypi.simple.v1+html;q=0.2",
"text/html;q=0.01", # For legacy compatibility
]
ACCEPT = ", ".join(CONTENT_TYPES)
# 把我们能够接受的内容类型以列表的形式发送给服务端
# 并让服务端从中选择一种合适的内容类型用于响应
resp = requests.get("https://pypi.org/simple/", headers={"Accept": ACCEPT})
# 当服务端不支持我们提供的任何类型时
# 如果服务端返回了一个 HTTP 406 错误码(而不是一个默认内容类型的响应)
# 此处将会抛出对此抛出一个异常
resp.raise_for_status()
# 分析来自服务端的响应类型以确保该类型确实是客户端能够处理的
# 如果能够处理,则将其分发给针对指定版本以及序列化方式的处理函数进行处理
# 如果客户端无法理解收到的数据类型,则抛出异常
content_type = parse_content_type(resp.headers.get("content-type", ""))
match content_type:
case "application/vnd.pypi.simple.v1+json":
handle_v1_json(resp)
case "application/vnd.pypi.simple.v1+html" | "text/html":
handle_v1_html(resp)
case _:
raise Exception(f"Unknown content type: {content_type}")
如果客户端只希望支持 HTML,或者只希望支持 JSON,则可以在 Accept
字段中删除掉自己不想支持的响应内容类型,当受到自己不想接受的响应类型时将其视为出错。
虽然使用 HTTP 内容协商是一种客户端与服务端进行内容类型协商的标准方式,但在某些情况下,该机制可能并不够完备,针对这些情况,本 PEP 提供了一种可选的(optionally)协商机制。
支持简单 API 的服务端可以选择支持名为 format
的 URL 参数以允许客户端请求一个指定版本的 URL。
format
参数的值应当是可用的内容类型之一,不支持传入多个可用类型、通配符以及质量等级的语法。
对 URL 参数的支持是可选的,客户端在使用 API 时**不应该(SHOULD NOT)**依赖这一点。URL 参数方式的内容类型协商,主要可以便于使用浏览器访问简单 API ,或者用于某些手册文档中需要指明链接具体版本+格式的地方。
不支持 URL 参数的服务端可以在看到这一参数时返回错误,也可以直接在进行内容响应时忽略这一参数。
当服务端支持 URL 参数这一特性时,它应当比客户端请求头中给出的 Accept
字段中的所有类型都具有更高的优先级。当服务端不支持这一特性时,它可以选择倒退回使用 Accept
字段,或者采用在标准的服务端驱动内容协商部分介绍的三种响应方式之一进行响应(例如:406 Not Available
、300 Multipule Choices
,或者选择返回一种默认的内容类型)。
2024-08-10
译者注:
https://github.com/python/peps/commit/b1d591f26f5f0458155665b5da6bb91f03c004c3
这个 commit 里把300 Multipule Choices
错误地写成了303 Multipule Choices
。
这个可选功能在技术上并不是一个特殊的选择,它是使用内容协商的自然结果,它使得服务端可以选择何种内容类型是它的 “默认类型”
当服务端不能或者不愿意实现服务端驱动的内容协商,而是需要由用户来显示配置客户端去选择它们想要的类型时,本节提供了一种可用的配置方式。
为了实现这一点,服务端可以为每个类型+序列化格式设立一个 URL 根端点(例如 /simple/v1+html/
或者 /simple/v1+json/
)。在这些端点下,它们可以为那些仅支持某种(或某几种)内容类型的项目提供服务。当客户端向带有版本类型信息的 URL 发起请求时,服务端应当直接忽略请求头中的 Accept
字段,而直接返回与端点配置类型相一致的内容类型。
PEP 458 要求所有 API 的响应都应该是可哈希的,且它们可以相对代码仓库跟路径的一个相对路径唯一确定。对于简单 API 而言,这个目标根路径就是我们 API 的根 URL (例如 PyPI 中,这个根 URL 为 /simple/
)。当我们不直接使用标准的 HTTP 客户端而是用 TUF 客户端访问 API 时就会遇到困难,因为 TUF 不能接受同一个目标对象有多种不同的合法表示,而这些合法表示间具有不同的哈希校验和。
PEP 458 并没有显式指明应当采用何种的目标路径,但 TUF 要求目标路径必须是 “文件” 而非 “文件夹”,换言之,诸如 simple/PROJECT/
的路径就是 TUF 所不能接受的,因为技术上这个路径指向了一个文件夹而不是一个文件。
值得庆幸的是,目标路径并不一定要与简单 API 要求的 URL 完全一致,目标路径可以只是一个标记,而服务端知道这个标记应该如何被转换为实际的被访问对象。在 HTTP 请求的其他方面例如 Accept
请求头也可以采用类似的处理思路。
给出将文件夹路径映射到文件名的具体过程超出了本 PEP 的讨论范畴(该话题属于 PEP 458 的讨论范畴)。关于 PEP 458 中应当如何规定路径映射,本 PEP 推迟了在 PEP 458 元数据中呈现这一点的决定。
然而,针对 pip 的正在进行中的 PEP 458 实现使用了类似 simple/PROJECT/index.html
的路径。这种策略也可以被改为使用类似 simple/PROJECT/vnd.pypi.simple.vN.格式
的路径以支持版本号和序列化格式。因此,版本 v1 的 HTML 响应将对应着路径 simple/PROJECT/vnd.pypi.simple.v1.html
,版本 v1 的 JSON 格式将对应着 simple/PROJECT/vnd.pypi.simple.v1.json
。
在这种情况下,由于通过 TUF 交互时 text/html
是 application/vnd.pypi.simple.v1+html
的别名,因此将内容类别规范化为更确切的名称可能更有指导意义。
类似地,元类型 latest
不应该在目标中 被使用,服务端之应该支持显式地版本名。
本节内容并不是规范的一部分,本节内容给出了本 PEP 作者认为的最佳默认实现。但这并不代表本 PEP 的具体实现必须参照本文进行实现。