PEP 694 – Python 包仓库的 Upload 2.0 API
- 作者:
- Donald Stufft <donald at stufft.io>
- 讨论邮件列表:
- Discourse 帖子
- 状态:
- 草稿
- 类型:
- 标准跟踪
- 主题:
- 打包
- 创建:
- 2022年6月11日
- 历史记录:
- 2022年6月27日
摘要
目前还没有用于将文件上传到 Python 包仓库(如 PyPI)的标准化 API。相反,每个人都不得不从 PyPI 反向工程非标准 API。
虽然该 API 能够正常工作,但它泄露了许多原始 PyPI 代码库的实现细节,这些细节现在必须在新代码库和替代实现中忠实地复制。
除此之外,当前 API 还存在许多重大问题
- 它是一个完全同步的 API,这意味着我们被迫让单个请求保持打开状态,可能很长时间,这既包括上传本身,也包括仓库处理上传的文件以确定成功或失败。
- 它不支持任何恢复上传的机制,PyPI 上最大的文件大小略低于 1GB,如果一个大文件在上传结束时出现网络故障,那将浪费大量的带宽。
- 它将单个文件视为操作的原子单元,这在发布可能有多个二进制轮子时可能存在问题,这会导致人们在文件上传时获得不同的版本,如果 sdist 碰巧不是最后一个,则可能某些难以构建的包会尝试从源代码构建。
- 它对向用户反馈的支持非常有限,不支持多个错误、警告、弃用等。它完全限于 HTTP 状态码和原因短语,而原因短语自 HTTP/2 以来已被弃用(RFC 7540)。
- 发布/文件的元数据与文件一起提交,但是此元数据众所周知是不可靠的,大多数安装程序反而选择下载整个文件并部分读取它,部分原因是这种不可靠性。
- 没有机制允许仓库在带宽开始用于上传之前进行任何类型的健全性检查,而许多无效元数据或权限错误的情况可以在上传之前进行检查。
- 它不支持在将草稿发布到仓库之前“暂存”草稿发布。
- 它不支持在不上传文件的情况下创建新项目。
本 PEP 提出了一种新的上传 API,并弃用现有的非标准 API。
现状
这并非试图对当前 API 进行完全详尽的文档记录,而是提供对现有 API 的高级概述。
端点
现有的上传 API(以及现在已删除的注册 API)位于一个 URL 上,当前为 https://upload.pypi.org/legacy/
,要告知要调用的特定 API,您需要添加一个 :action
URL 参数,其值为 file_upload
。 submit
、submit_pkg_info
和 doc_upload
的值也曾经受支持,但现在已不再支持。
它还有一个 protocol_version
参数,理论上允许编写新版本的 API,但在实践中从未发生过,并且该值始终为 1
。
因此,在实践中,在 PyPI 上,端点为 https://upload.pypi.org/legacy/?:action=file_upload&protocol_version=1
。
编码
要提交的数据作为 POST
请求以 multipart/form-data
的内容类型提交。这是由于历史原因,该 API 实际上并非设计为 API,而是最初 PyPI 实现中的表单,然后编写客户端代码以编程方式提交该表单。
内容
粗略地说,包中包含的元数据作为内容处置为 form-data
且名称为字段名称的部分提交。这些各种元数据名称未记录,并且有时(但并非总是)与 METADATA
文件中使用的名称匹配。大小写很少匹配,但总体而言,METADATA
到 form-data
的转换非常不一致。
然后,文件本身作为名称为 content
的 application/octet-stream
部分发送,如果附加了 PGP 签名,则将其作为名称为 gpg_signature
的 application/octet-stream
部分包含。
规范
本 PEP 将现有 API 的大多数问题根本原因追溯到大约两件事
- 元数据与文件一起提交,而不是从文件本身解析。
- 如果用作预检查,这实际上是可以的,但应针对分发版中的实际
METADATA
或类似文件进行验证。
- 如果用作预检查,这实际上是可以的,但应针对分发版中的实际
- 它支持单个请求,仅使用表单数据,该请求要么成功要么失败,并且所有操作都在该单个请求中完成并包含。
然后,我们提出一个多请求工作流,它基本上归结为
- 启动上传会话。
- 将文件作为上传会话的一部分上传。
- 完成上传会话。
- (可选)检查上传会话的状态。
此处描述的所有 URL 将相对于根端点,根端点可能位于域的 URL 结构中的任何位置。因此,它可能位于 https://upload.example.com/
或 https://example.com/upload/
。
版本控制
本 PEP 使用与 PEP 691 中使用的相同的 MAJOR.MINOR
版本控制系统,但除此之外是独立版本控制的。本规范认为现有 API 的版本为 1.0
,但除此之外,它不尝试以任何方式修改该 API。
端点
创建上传会话
要创建一个新的上传会话,您可以向 /
发送一个 POST
请求,其有效负载如下所示
{
"meta": {
"api-version": "2.0"
},
"name": "foo",
"version": "1.0"
}
这目前有三个键,meta
、name
和 version
。
meta
键包含在所有有效负载中,它描述了有效负载本身的信息。
name
键是此会话尝试向其添加文件的项目的名称。
version
键是此会话尝试向其添加文件的项目的版本。
如果成功创建会话,则服务器必须返回如下所示的响应
{
"meta": {
"api-version": "2.0"
},
"urls": {
"upload": "...",
"draft": "...",
"publish": "..."
},
"valid-for": 604800,
"status": "pending",
"files": {},
"notices": [
"a notice to display to the user"
]
}
除了 meta
键之外,此响应还有五个键,urls
、valid-for
、status
、files
和 notices
。
urls
键是一个字典,将标识符映射到与此会话相关的 URL。
valid-for
键是一个整数,表示服务器本身将在多长时间(以秒为单位)后使此会话过期(以及其中包含的所有 URL)。会话**应该**至少持续这么长时间,除非客户端本身已取消会话。服务器**可以**选择延长此时间,但绝不应缩短此时间,除非自然地随着时间的推移。
status
键是一个字符串,包含 pending
、published
、errored
或 canceled
之一,此字符串表示会话的总体状态。
files
键是一个映射,包含已上传到此会话的文件名,以及包含每个文件详细信息的映射。
notices
键是一个可选键,它指向服务器希望传达给最终用户的通知数组,这些通知不特定于任何一个文件。
对于 files
中的每个文件名,映射都有三个键,status
、url
和 notices
。
status
键与顶级 status
键相同,只是它指示特定文件的状态。
url
键是客户端应将特定文件上传到的(或用于删除该文件)的*绝对* URL。
notices
键是一个可选键,它是一个通知数组,服务器希望传达给最终用户的通知,这些通知特定于此文件。
成功创建会话的所需响应代码是 201 Created
响应,并且**必须**包含一个 Location
标头,该标头是此会话的 URL,可用于检查其状态或取消它。
对于 urls
键,当前可能有三个键出现
“upload
” 键,表示用于此会话启动文件上传的上传端点。
“draft
” 键,表示这些文件在发布之前所在的仓库 URL。
“publish
” 键,表示触发发布会话的端点。
除了以上内容外,如果为相同名称+版本对创建第二个会话,则上传服务器**必须**返回已存在的会话,而不是创建新的空会话。
上传每个文件
一旦您为一个或多个文件启动了上传会话,则必须实际上传每个文件。
没有用于实际上传文件的固定端点,该端点由服务器作为上传会话创建的一部分提供给客户端,客户端**不得**假设这些 URL 从一个会话到下一个会话有任何共性。
要启动文件上传,客户端向会话中的上传 URL 发送 POST
请求,请求主体如下所示
{
"meta": {
"api-version": "2.0"
},
"filename": "foo-1.0.tar.gz",
"size": 1000,
"hashes": {"sha256": "...", "blake2b": "..."},
"metadata": "..."
}
除了标准的 meta
键之外,当前还有 4 个键
filename
:正在上传的文件的文件名。size
:正在上传的文件的大小(以字节为单位)。hashes
:哈希名称到十六进制编码摘要的映射,每个摘要都是该文件使用名称中标识的哈希算法计算出的摘要。默认情况下,任何通过 hashlib 可用的哈希算法(特别是任何可以传递给
hashlib.new()
并且不需要其他参数的算法)都可以用作哈希字典的键。**必须**始终包含来自hashlib.algorithms_guaranteed
的至少一个安全算法。在本 PEP 发布时,特别推荐使用sha256
。可以一次传递多个哈希值,但所有哈希值必须对文件有效。
metadata
:一个可选键,包含文件的 核心元数据 的字符串。
服务器**可以**使用此响应中提供的数据在允许上传文件之前进行一些健全性检查,这些检查可能包括但不限于
- 检查
filename
是否已存在。 - 检查
size
是否会使某些配额失效。 - 检查
metadata
(如果提供)的内容是否有效。
如果服务器确定客户端应该尝试上传,它将返回 201 Created
响应,响应主体为空,并且 Location
标头指向应将文件本身上传到的 URL。
此时,会话的状态应显示文件名,其中包含上述 URL。
上传数据
要上传文件,客户端有两个选择,他们可以将文件上传为单个块或多个块。两种选项都可以接受,但建议大多数客户端应选择将每个文件上传为单个块,因为这需要较少的请求并且通常具有更好的性能。
但是,对于特别大的文件,在单个请求中上传可能会导致超时,因此较大的文件可能需要分多个块上传。
无论哪种情况,客户端都必须为每个文件的每个上传尝试生成一个唯一的令牌(或随机数),并且**必须**在 Upload-Token
标头中的每个请求中包含该令牌。Upload-Token
是使用 base64 编码的二进制 blob,两侧用 :
包裹。客户端**应该**使用至少 32 字节的加密随机数据。您可以使用以下方法生成它
import base64
import secrets
header = ":" + base64.b64encode(secrets.token_bytes(32)).decode() + ":"
允许从上传请求中省略 Upload-Token
的唯一情况是,当客户端希望完全选择退出可恢复或分块文件上传功能时。在这种情况下,他们**可以**省略 Upload-Token
,并且必须在单个 HTTP 请求中成功上传文件,如果失败,则必须在另一个单个 HTTP 请求中重新发送整个文件。
要以单个块上传,客户端向会话响应中该文件名的 URL 发送 POST
请求。客户端**必须**包含一个 Content-Length
标头,该标头等于文件的大小(以字节为单位),并且**必须**与原始会话创建中提供的大小匹配。
例如,如果上传一个 100,000 字节的文件,您将发送如下标头
Content-Length: 100000
Upload-Token: :nYuc7Lg2/Lv9S4EYoT9WE6nwFZgN/TcUXyk9wtwoABg=:
如果上传成功完成,服务器**必须**以 201 Created
状态进行响应。此时,此文件**不得**存在于存储库中,而仅处于暂存状态,直到上传会话完成。
要分多个块上传,客户端向与之前相同的 URL 发送多个 POST
请求,每个块一个。
但是这一次,Content-Length
等于他们正在发送的块的大小(以字节为单位)。此外,客户端**必须**包含一个 Upload-Offset
标头,该标头指示此请求中包含的内容开始的字节偏移量,以及一个设置为 1
的 Upload-Incomplete
标头。
例如,如果将 100,000 字节的文件分成 1000 字节的块上传,并且此块表示字节 1001 到 2000,您将发送如下标头
Content-Length: 1000
Upload-Token: :nYuc7Lg2/Lv9S4EYoT9WE6nwFZgN/TcUXyk9wtwoABg=:
Upload-Offset: 1001
Upload-Incomplete: 1
但是,数据的**最后一个**块省略了 Upload-Incomplete
标头,因为此时上传不再不完整。
对于每个成功的块,服务器**必须**以 202 Accepted
标头进行响应,除了最后一个块,它**必须**为 201 Created
。
无论上传是单个块还是多个块,都会对上传施加以下约束
- 客户端**不得**对同一文件并行执行多个
POST
请求,以避免竞争条件以及数据丢失或损坏。服务器**可以**终止任何使用相同Upload-Token
的正在进行的POST
请求。 - 如果
Upload-Offset
中提供的偏移量不是0
或不完整上传中的下一个块,则服务器**必须**以 409 Conflict 响应。 - 一旦使用特定令牌启动了上传,则在删除正在进行的上传之前,不能对该文件使用其他令牌。
- 一旦文件成功上传,您可以为该文件启动另一个上传,这样做将替换该文件。
恢复上传
要恢复上传,您首先必须知道服务器已经接收了多少数据,无论您最初是将文件作为单个块上传还是分多个块上传。
要获取单个上传的状态,客户端可以使用其现有的 Upload-Token
对他们正在上传到的相同 URL 发出 HEAD
请求。
服务器**必须**以 204 No Content
响应进行响应,并带有 Upload-Offset
标头,该标头指示客户端应从哪个偏移量继续上传。如果服务器没有收到任何数据,则这将是 0
,如果它已收到 1007 个字节,则它将是 1007
。
一旦客户端检索到需要开始的偏移量,他们就可以按照上述说明上传文件的其余部分,无论是包含所有剩余数据的单个请求还是多个块。
取消正在进行的上传
如果客户端希望取消特定文件的上传(例如,因为他们需要上传不同的文件),他们可以通过向文件上传 URL 发出 DELETE
请求来执行此操作,该 URL 使用用于首次上传文件的 Upload-Token
。
成功的取消请求**必须**以 204 No Content
响应。
删除已上传的文件
已上传的文件可以通过向文件上传 URL 发出 DELETE
请求来删除,无需使用 Upload-Token
。
成功的删除请求**必须**以 204 No Content
响应。
会话状态
与文件上传类似,会话 URL 在创建上传会话的响应中提供,客户端**不得**假设这些 URL 从一个会话到下一个会话有任何共性。
要检查会话的状态,客户端向会话 URL 发出 GET
请求,服务器将以与他们最初创建上传会话时获得的相同响应进行响应,但会反映 status
、valid-for
或更新的 files
的任何更改。
会话取消
要取消上传会话,客户端向与之前相同的会话 URL 发出 DELETE
请求。此时,服务器将会话标记为已取消,**可以**清除作为该会话一部分上传的任何数据,并且将来尝试访问该会话 URL 或任何文件上传 URL**可以**返回 404 Not Found
。
为了防止大量悬空会话,服务器也可以选择自行取消会话。建议服务器在不少于一周后清除其会话,但每个服务器可以选择自己的计划。
会话完成
要完成会话并发布其中包含的文件,客户端**必须**向会话状态有效负载中的 publish
url 发送 POST
请求。
如果服务器能够立即完成会话,它可以这样做并返回 201 Created
响应。如果它无法立即完成会话(例如,如果它需要执行在单个 HTTP 请求中花费的时间过长的处理),则它可以返回 202 Accepted
响应。
无论哪种情况,服务器都应包含一个指向会话状态 URL 的 Location
标头,如果服务器返回了 202 Accepted
,则客户端可以轮询该 URL 以监视状态更改。
错误
所有包含正文的错误响应都将具有如下所示的正文
{
"meta": {
"api-version": "2.0"
},
"message": "...",
"errors": [
{
"source": "...",
"message": "..."
}
]
}
除了标准的 meta
键之外,它还有两个顶级键,message
和 errors
。
message
键是一个封装此请求中可能发生的所有错误的单一消息。
errors
键是一个特定错误数组,每个错误都包含一个 source
键(表示错误来源的字符串)和一个用于该特定错误的 message
键。
message
和 source
字符串没有任何特定含义,旨在供人类解释以找出潜在的问题。
内容类型
与 PEP 691 一样,本 PEP 建议上传 API 的所有请求和响应都将具有一个标准内容类型,用于描述内容是什么、表示 API 的哪个版本以及使用了什么序列化格式。
此内容类型的结构将为
application/vnd.pypi.upload.$version+format
由于只有主版本才会破坏试图理解其中一个 API 内容体的系统,因此内容类型中只会包含主版本,并在前面加上 v
以明确它是一个版本号。
与 PEP 691 不同,本 PEP 不会以任何方式更改现有的 1.0
API,因此服务器需要在与现有上传 API 不同的端点托管本 PEP 中描述的新 API。
这意味着对于新的 2.0 API,内容类型将为
- JSON:
application/vnd.pypi.upload.v2+json
除了上述内容外,还支持一个名为 latest
的特殊“元”版本,其目的是允许客户端请求绝对最新的版本,而无需事先知道该版本是什么。但是,建议客户端明确说明他们支持的版本。
这些内容类型不适用于文件上传本身,仅适用于上传 API 中的其他 API 请求/响应。文件本身应使用 application/octet-stream
内容类型。
版本+格式选择
同样类似于 PEP 691,本 PEP 将标准化使用服务器驱动的内容协商,以允许客户端请求不同的版本或序列化格式,其中包括 format
url 参数。
由于本 PEP 预计现有的遗留 1.0
上传 API 将存在于不同的端点,并且它目前仅提供 JSON 序列化,因此此机制并不是特别有用,客户端只有一个可以请求的版本和序列化。但是,客户端应该设置为在将来添加其他格式或版本的情况下优雅地处理内容协商。
常见问题
这意味着 PyPI 计划放弃对现有上传 API 的支持吗?
目前 PyPI 还没有任何关于停止支持现有上传 API 的具体计划。
与 PEP 691 不同,这样做有很多好处,因此我们很可能希望在将来某个时候停止对其的支持,但在实施此 API 之前,并且在获得广泛使用之前,制定任何停止支持它的计划都为时尚早。
这个可恢复上传协议基于什么?
是的!
它实际上是 活动互联网草案 中指定的协议,其中作者利用他们在实施 tus 中获得的经验,以完全通用、基于标准的方式提出了可恢复上传的想法。
我们与该规范的唯一偏差是我们没有在第一个 POST
请求中使用 104 Upload Resumption Supported
信息响应。做出此决定的原因有几个
104 Upload Resumption Supported
是该草案中唯一不完全依赖于现有标准中已支持的事物的部分,因为它添加了一种新的信息状态。- 许多客户端和 Web 框架并不好地支持
1xx
信息响应(如果支持的话),添加它会增加实现的复杂性,而收益却很小。 104 Upload Resumption Supported
支持的目的是允许客户端确定他们正在与之交互的任意端点是否支持可恢复上传。由于本 PEP 要求服务器支持此功能,因此客户端只需假设他们正在与之交互的服务器支持它,这使得使用它变得没有必要。- 理论上,如果
1xx
响应的支持得到解决并且草案在其中被接受,我们可以在以后的某个时间添加它,而无需更改 API 的整体流程。
上述草案可能不会被接受,但这并不会真正影响我们。这仅仅意味着我们对可恢复上传的支持是一个特定于应用程序的协议,但仍然完全符合标准。
未解决的问题
分块上传 vs tus
本 PEP 目前将文件的实际上传基于来自 tus.io 的一个支持可恢复文件上传的互联网草案。
该协议需要一些东西
- 客户端选择一个安全的
Upload-Token
来识别上传单个文件。 - 如果客户端没有一次上传整个文件,则必须按顺序且按正确顺序提交块,除了最后一个块之外,所有块都必须具有
Upload-Incomplete: 1
标头。 - 上传恢复本质上只是查询服务器以查看他们获得了多少数据,然后发送剩余的字节(作为单个请求或分块)。
- 当服务器成功从客户端获取所有数据时,上传会隐式完成。
这有一个很大的好处,那就是如果客户端不关心恢复下载,则客户端方面支持可恢复上传的工作可以完全忽略。他们只需将文件 POST
到 URL,如果失败,他们只需再次 POST
整个文件。
另一个好处是,即使您确实想要支持恢复,您仍然可以只 POST
文件,除非您需要恢复下载,否则这就是您需要做的全部。
另一个可能只是理论上的好处是,对于散列上传的文件,串行块要求意味着服务器可以在请求之间维护散列状态,为每个请求更新它,然后将该文件写回存储。不幸的是,使用 Python 的 hashlib 实际上无法做到这一点,尽管有一些像 Rehash 这样的库实现了它,但它们不支持 hashlib 的所有散列(具体来说,在撰写本文时不支持 blake2 或 sha3)。
我们可能还需要重建下载以进行处理,以便从其中提取元数据等,这将使其成为一个无关紧要的问题。
缺点是无法并行上传单个文件,因为每个块都必须按顺序提交。
AWS S3 具有类似的 API(大多数 Blob 存储已全部或部分复制了它),他们称之为分块上传。
分块上传的基本流程是
- 启动分块上传以获取上传 ID。
- 将文件分成块,并分别上传每个块。
- 所有块上传完成后,完成上传。- 这是发生任何错误的步骤。
它不直接支持恢复上传,但它允许客户端通过调整他们上传的每个部分的大小来控制故障的“影响范围”,如果任何部分失败,他们只需重新发送这些特定部分。
这具有很大的好处,因为它允许并行上传文件,允许客户端使用多个线程发送数据来最大化其带宽。
我们不需要显式步骤 (1),因为我们的会话将隐式地为每个文件启动分块上传。
它确实有自己的缺点
- 客户端必须在每个请求上做更多工作才能获得类似于可恢复上传的内容。他们必须将文件分成多个部分,而不是仅仅发出单个 POST 请求,并且只有在出现故障时才需要处理复杂性。
- 完全不关心恢复的客户端仍然必须处理第三个显式步骤,尽管他们可以将整个文件都作为一个部分上传。
- S3 通过为一次性上传提供另一个 API 来解决此问题,但我宁愿不要为上传同一个文件提供两个不同的 API。
- 验证散列变得稍微复杂了一些。AWS 通过散列每个部分来实现散列分块上传,然后总体散列只是这些散列的散列,而不是内容本身的散列。我们需要知道文件本身的实际散列才能用于 PyPI,因此我们必须在文件完全上传后重建文件并读取其内容并对其进行散列,尽管我们仍然可以使用散列的散列技巧来对上传本身进行校验和。
- 请参阅上面关于这在实践中是否真的是一个缺点,或者它是否仅仅是理论上的内容。
我倾向于使用 tus 风格的可恢复上传,因为我认为它们更易于使用和实现,主要缺点是我们可能会放弃一些多线程性能,而我个人对此是可以接受的?
我想 S3 风格的多部分上传的一个额外好处是,您不必尝试进行任何类型的并行上传保护,因为它们本身就是受支持的。仅此一项就可能消除大部分服务器端实现的简化。
版权
本文档放置在公共领域或根据 CC0-1.0-通用许可证,以较宽松者为准。
来源:https://github.com/python/peps/blob/main/peps/pep-0694.rst