PEP 517 – 獨立於建置系統的原始碼樹格式
- 作者:
- Nathaniel J. Smith <njs at pobox.com>, Thomas Kluyver <thomas at kluyver.me.uk>
- BDFL-Delegate:
- Alyssa Coghlan <ncoghlan at gmail.com>
- 討論於:
- Distutils-SIG 郵件列表
- 狀態:
- 最終 (Final)
- 類型:
- 標準軌跡 (Standards Track)
- 主題:
- 套件封裝 (Packaging)
- 建立日期:
- 2015-09-30
- 公告歷史:
- 2015-10-01, 2015-10-25, 2017-05-19, 2017-09-11
- 決議:
- Distutils-SIG 訊息
摘要
儘管 distutils / setuptools 陪伴我們走過了很長一段路,但它們存在三個嚴重的問題:(a) 它們缺少重要的功能,如可用的建置期依賴宣告、自動配置,甚至連基礎的工程學需求(如符合 DRY 原則的版本號管理)都缺乏;(b) 擴充它們非常困難,因此雖然針對上述問題存在各種解決方案,但它們往往古怪、脆弱且維護成本高昂;(c) 由於 distutils/setuptools 為使用者和如 pip 這類安裝工具提供了標準的套件安裝介面,因此很難使用其他方案來取代它們。
先前的努力(例如 distutils2 或 setuptools 本身)曾試圖解決 (a) 和/或 (b) 問題。本提案旨在解決 (c) 問題。
本 PEP 的目標是讓 distutils-sig 不再擔任 Python 建置系統的守門人。如果您想使用 distutils,很好;如果您想使用其他工具,那麼透過標準化方法應該要能輕鬆達成。目前與 distutils 對接的難度意味著市面上並沒有太多這類系統,但若想了解我們的想法,請參考 flit 或 bento。幸運的是,Wheel 格式目前已解決了許多棘手問題(例如,建置系統不再需要了解所有可能的安裝配置),因此我們實際上只需要建置系統提供一種輸出符合標準的 Wheel 和 sdist 的方式即可。
因此,我們為如 pip 這類的安裝工具提出了一種新的、相對精簡的介面,用於與套件原始碼樹及原始碼發布包互動。
術語與目標
原始碼樹 (source tree) 指的是像 VCS 的 checkout 副本之類的東西。我們需要一個標準介面來從此格式進行安裝,以支援如 pip install some-directory/ 這類用法。
原始碼發布包 (source distribution) 是代表特定版本原始碼的靜態快照,例如 lxml-3.4.4.tar.gz。原始碼發布包有多種用途:它們構成了發布版本的存檔紀錄、為希望讀取和處理大型程式碼庫(可能由多種語言編寫,如程式碼搜尋)的工具提供了簡單明瞭的事實標準,並作為 Debian/Fedora/Conda/... 等下游封裝系統的輸入等等。在 Python 生態系統中,它們還有一個特別重要的角色,因為像 pip 這類的封裝工具能夠使用原始碼發布包來滿足二進位依賴關係。例如,若有一個發布包 foo.whl 宣告了對 bar 的依賴,那麼我們就需要支援當執行 pip install bar 或 pip install foo 時,自動定位 bar 的 sdist、下載、建置並安裝產生的套件。
原始碼發布包簡稱為 sdists。
建置前端 (build frontend) 是一種使用者可能會執行的工具,它接收任意原始碼樹或原始碼發布包並從中建置 Wheel。實際的建置工作由每個原始碼樹的 建置後端 (build backend) 完成。在 pip wheel some-directory/ 這樣的指令中,pip 扮演的就是建置前端的角色。
整合前端 (integration frontend) 是一種使用者可能會執行的工具,它接收一套套件需求(例如 requirements.txt 檔案)並試圖更新工作環境以滿足這些需求。這可能需要定位、建置並安裝 Wheel 和 sdist 的組合。在 pip install lxml==2.4.0 這樣的指令中,pip 扮演的就是整合前端的角色。
原始碼樹
目前存在一種包含 setup.py 的遺留原始碼樹格式。我們不嘗試對其進行進一步規範;其事實上的規範已編碼在 distutils、setuptools、pip 及其他工具的原始碼和文件中。我們將其稱為 setup.py 風格。
在此,我們定義了一種新的原始碼樹風格,基於 PEP 518 中定義的 pyproject.toml 檔案,並在該檔案的 [build-system] 表格中增加一個額外的鍵值 build-backend。以下是一個範例:
[build-system]
# Defined by PEP 518:
requires = ["flit"]
# Defined by this PEP:
build-backend = "flit.api:main"
build-backend 是一個字串,用來命名將執行建置任務的 Python 物件(詳細資訊請參閱下文)。其格式遵循與 setuptools 入口點相同的 module:object 語法。例如,若字串如上例所示為 "flit.api:main",則此物件將透過執行類似以下的程式碼來尋找:
import flit.api
backend = flit.api.main
也可以省略 :object 部分,例如:
build-backend = "flit.api"
這等同於:
import flit.api
backend = flit.api
形式上,該字串應滿足此文法:
identifier = (letter | '_') (letter | '_' | digit)*
module_path = identifier ('.' identifier)*
object_path = identifier ('.' identifier)*
entry_point = module_path (':' object_path)?
我們匯入 module_path,然後查找 module_path.object_path(若 object_path 缺失,則僅查找 module_path)。
在匯入模組路徑時,我們不會在包含原始碼樹的目錄中尋找,除非該目錄本來就在 sys.path 中(例如因為它是在 PYTHONPATH 中指定的)。儘管 Python 在某些情況下會自動將工作目錄添加到 sys.path 中,但用於解析後端的程式碼不應受到此影響。
若不存在 pyproject.toml 檔案,或者缺失 build-backend 鍵值,則該原始碼樹不使用本規範,工具應回退到執行 setup.py 的遺留行為(直接執行,或是隱式呼叫 setuptools.build_meta:__legacy__ 後端)。
當 build-backend 鍵值存在時,它具有優先權,且原始碼樹會遵循該後端的格式和約定(因此,除非後端需要,否則不需要 setup.py)。專案可能仍希望包含一個 setup.py 以保持對未使用此規範的工具的相容性。
本 PEP 也定義了一個用於 pyproject.toml 的 backend-path 鍵值,請參閱下方的「樹內建置後端」章節。此鍵值的使用方式如下:
[build-system]
# Defined by PEP 518:
requires = ["flit"]
# Defined by this PEP:
build-backend = "local_backend"
backend-path = ["backend"]
建置需求
本 PEP 對 pyproject.toml 的「建置需求」部分提出了若干額外要求。這些要求旨在確保專案不會建立無法滿足的建置需求條件。
- 專案建置需求將定義一個有向需求圖(專案 A 需要 B 才能建置,B 需要 C 和 D,以此類推)。此圖表不得包含迴圈。若(例如由於專案間缺乏協調)出現了迴圈,前端可以拒絕建置該專案。
- 若建置需求以 Wheel 形式提供,前端應在切實可行時使用它們,以避免深度巢狀的建置。然而,前端可能存在不考慮 Wheel 來定位建置需求的模式,因此專案不得假設發布 Wheel 足以打破需求迴圈。
- 前端應明確檢查需求迴圈,若發現迴圈,應以資訊豐富的訊息終止建置。
特別注意,沒有需求迴圈的要求意味著希望進行自託管的後端(即,建置後端的 Wheel 時會使用該後端進行建置)需要採取特殊措施以避免導致迴圈。通常這涉及將自己指定為樹內後端,並避免外部建置依賴(通常透過 vendoring 處理)。
建置後端介面
建置後端物件預期擁有提供以下部分或全部掛鉤的屬性。通用的 config_settings 引數將在個別掛鉤後說明。
強制性掛鉤
build_wheel
def build_wheel(wheel_directory, config_settings=None, metadata_directory=None):
...
必須建置一個 .whl 檔案,並將其放置在指定的 wheel_directory 中。它必須以 Unicode 字串形式回傳所建立 .whl 檔案的基底名稱(而非完整路徑)。
如果建置前端先前已呼叫 prepare_metadata_for_build_wheel,且依賴於由此呼叫產生的 Wheel 來匹配該先前的呼叫,則應將所建立 .dist-info 目錄的路徑作為 metadata_directory 引數提供。若提供了此引數,則 build_wheel 必須產生一個具有相同 metadata 的 Wheel。建置前端傳入的目錄必須與 prepare_metadata_for_build_wheel 所建立的目錄完全相同,包括它建立的任何無法辨識的檔案。
未提供 prepare_metadata_for_build_wheel 掛鉤的後端,可以默默忽略傳給 build_wheel 的 metadata_directory 參數,或者在該參數被設為非 None 時拋出例外。
為了確保來自不同來源的 Wheel 以相同方式建置,前端可能會先呼叫 build_sdist,然後在解壓縮後的 sdist 中呼叫 build_wheel。但如果後端表示它缺少建立 sdist 的某些需求(見下文),前端將回退到在原始碼目錄中呼叫 build_wheel。
原始碼目錄可能是唯讀的。因此,後端應準備好在不建立或修改原始碼目錄中任何檔案的情況下進行建置,但它們也可以選擇不處理這種情況,在這種情況下,失敗將對使用者可見。前端不負責對唯讀原始碼目錄進行任何特殊處理。
後端可能會將中間產物儲存在快取位置或臨時目錄中。任何快取的存在與否不應對最終的建置結果產生實質影響。
build_sdist
def build_sdist(sdist_directory, config_settings=None):
...
必須建置一個 .tar.gz 原始碼發布包,並將其放置在指定的 sdist_directory 中。它必須以 Unicode 字串形式回傳所建立 .tar.gz 檔案的基底名稱(而非完整路徑)。
.tar.gz 原始碼發布包 (sdist) 包含一個名為 {name}-{version}(例如 foo-1.0)的單一頂層目錄,其中包含套件的原始碼檔案。此目錄還必須包含來自建置目錄的 pyproject.toml,以及包含 PEP 345 中所述格式 metadata 的 PKG-INFO 檔案。雖然歷史上 zip 檔案也被用作 sdist,但此掛鉤應產生一個 gzipped tarball。這是目前更常見的 sdist 格式,且保持格式一致性可以簡化工具的開發。
產生的 tarball 應使用現代的 POSIX.1-2001 pax tar 格式,該格式指定了基於 UTF-8 的檔案名稱。這在 Python 3.6 附帶的 tarfile 模組中尚非預設值,因此使用 tarfile 模組的後端需要明確傳入 format=tarfile.PAX_FORMAT。
某些後端可能有建立 sdist 的額外要求,例如版本控制工具。然而,一些前端可能傾向於在產生 Wheel 時製作中間 sdist,以確保一致性。如果後端因為缺少依賴或其他易於理解的原因而無法產生 sdist,它應拋出一個特定類型的例外,並在後端物件上將其作為 UnsupportedOperation 提供。如果前端在建置作為 Wheel 中間體的 sdist 時收到此例外,則應回退到直接建置 Wheel。如果後端永遠不會拋出此例外,則無需定義此例外類型。
選用掛鉤
get_requires_for_build_wheel
def get_requires_for_build_wheel(config_settings=None):
...
此掛鉤必須回傳一個包含 PEP 508 依賴規範的額外字串列表,這些規範超出 pyproject.toml 檔案中指定的範圍,並將在呼叫 build_wheel 或 prepare_metadata_for_build_wheel 掛鉤時進行安裝。
範例
def get_requires_for_build_wheel(config_settings):
return ["wheel >= 0.25", "setuptools"]
如果未定義,預設實作等同於 return []。
prepare_metadata_for_build_wheel
def prepare_metadata_for_build_wheel(metadata_directory, config_settings=None):
...
必須在指定的 metadata_directory 內建立一個包含 Wheel metadata 的 .dist-info 目錄(即建立一個類似 {metadata_directory}/{package}-{version}.dist-info/ 的目錄)。此目錄必須是 Wheel 規範中定義的有效 .dist-info 目錄,除了它不需要包含 RECORD 或簽章。該掛鉤也可能在此目錄內建立其他檔案,而建置前端必須保留這些檔案,但除此之外應忽略它們;這裡的用意是,在 metadata 取決於建置時決策的情況下,建置後端可能需要將這些決策記錄在某種方便的格式中,以便實際的 Wheel 建置步驟重複使用。
這必須以 Unicode 字串形式回傳所建立 .dist-info 目錄的基底名稱(而非完整路徑)。
如果建置前端需要此資訊但該方法未定義,則應呼叫 build_wheel 並直接查看產生的 metadata。
get_requires_for_build_sdist
def get_requires_for_build_sdist(config_settings=None):
...
此掛鉤必須回傳一個包含 PEP 508 依賴規範的額外字串列表,這些規範超出 pyproject.toml 檔案中指定的範圍。這些依賴項將在呼叫 build_sdist 掛鉤時安裝。
如果未定義,預設實作等同於 return []。
附註
可編輯安裝 (Editable installs)
本 PEP 最初指定了另一個掛鉤 install_editable,用於執行可編輯安裝(如同 pip install -e)。但由於該主題的複雜性,它已被移除,未來可能會在稍後的 PEP 中指定。
簡而言之,需要回答的問題包括:現存的實現「可編輯安裝」的合理方式有哪些?應該由後端還是前端來選擇如何進行可編輯安裝?如果由前端決定,它需要後端提供什麼資訊才能完成此操作。
配置設定
config_settings
此參數會傳遞給所有掛鉤,它是一個任意字典,作為使用者將臨時配置傳入個別套件建置的「逃生艙 (escape hatch)」。建置後端可以對此字典賦予任何它們喜歡的語義。建置前端應提供某種機制,讓使用者指定任意的字串鍵/值對,並放入此字典中。例如,它們可能支援類似 --package-config CC=gcc 的語法。如果使用者提供了重複的字串鍵,建置前端應將對應的字串值組合成一個字串列表。建置前端也可以提供其他任意機制,供使用者在該字典中放入條目。例如,pip 可能選擇將現代和遺留命令列參數的組合映射為:
pip install \
--package-config CC=gcc \
--global-option="--some-global-option" \
--build-option="--build-option1" \
--build-option="--build-option2"
映射為 config_settings 字典,如下所示:
{
"CC": "gcc",
"--global-option": ["--some-global-option"],
"--build-option": ["--build-option1", "--build-option2"],
}
當然,使用者必須確保他們傳遞的選項對於他們正在建置的特定建置後端和套件是有意義的。
掛鉤可能會以位置或關鍵字參數被呼叫,因此實作這些掛鉤的後端應確保其簽章符合上述參數的順序和名稱。
所有掛鉤的執行環境工作目錄皆設定為原始碼樹的根目錄,並且可以將任意資訊性文字列印到 stdout 和 stderr。它們不得讀取 stdin,建置前端可以在呼叫掛鉤之前關閉 stdin。
建置前端可能會捕獲來自後端的 stdout 和/或 stderr。如果後端偵測到輸出串流不是終端機/主控台(例如 not sys.stdout.isatty()),它應確保寫入該串流的任何輸出都以 UTF-8 編碼。若捕獲的輸出不是有效的 UTF-8,建置前端不得失敗,但它可能無法保留該情況下的所有資訊(例如,它可能會在 Python 中使用 replace 錯誤處理器來解碼)。如果輸出串流是一個終端機,則建置後端有責任準確呈現其輸出,就像終端機中執行的任何程式一樣。
如果掛鉤拋出例外或導致處理程序終止,則表示出現錯誤。
建置環境
建置前端的責任之一是設定執行建置後端的 Python 環境。
我們不要求使用任何特定的「虛擬環境」機制;建置前端可以使用 virtualenv、venv 或根本不使用特殊機制。但無論使用何種機制,都必須滿足以下標準:
- 專案的 build-requirements 所指定的所有需求都必須能從 Python 匯入。特別是:
get_requires_for_build_wheel和get_requires_for_build_sdist掛鉤是在包含pyproject.toml檔案中指定之引導需求 (bootstrap requirements) 的環境中執行。prepare_metadata_for_build_wheel和build_wheel掛鉤是在包含來自pyproject.toml的引導需求以及由get_requires_for_build_wheel掛鉤指定需求的環境中執行。build_sdist掛鉤是在包含來自pyproject.toml的引導需求以及由get_requires_for_build_sdist掛鉤指定需求的環境中執行。
- 即使對於由建置環境產生的新 Python 子處理程序,這也必須保持成立,例如類似以下的程式碼:
import sys, subprocess subprocess.check_call([sys.executable, ...])
必須產生一個能夠存取專案所有 build-requirements 的 Python 處理程序。這對於想要在子處理程序中執行遺留
setup.py指令稿的建置後端來說是必要的。 - 由 build-required 套件提供的所有命令列指令稿必須存在於建置環境的 PATH 中。例如,如果專案宣告了對 flit 的 build-requirement,則以下內容必須能作為執行 flit 命令列工具的機制:
import subprocess import shutil subprocess.check_call([shutil.which("flit"), ...])
建置後端必須準備好在任何滿足上述標準的環境中運作。特別是,它不得假設自己可以存取除 stdlib 中現有套件或明確宣告為 build-requirements 之外的任何套件。
前端應在獨立的子處理程序中呼叫每個掛鉤,以便後端可以自由更改處理程序全域狀態(例如環境變數或工作目錄)。我們將提供一個 Python 函式庫,供前端輕鬆地以此方式呼叫掛鉤。
給建置前端的建議(非規範性)
建置前端可以使用任何滿足上述標準的機制來設定建置環境。例如,僅將所有 build-requirements 安裝到全域環境就足以建置任何符合規範的套件——但這在多種原因下並非最佳做法。本節包含給前端實作者的非規範性建議。
預設情況下,建置前端應為每個建置建立一個隔離環境,僅包含標準函式庫和任何明確請求的 build-dependencies。這有兩個好處:
- 它允許單次安裝執行過程中建置多個具有矛盾 build-requirements 的套件。例如,如果 package1 的 build-requires 為 pbr==1.8.1,而 package2 的 build-requires 為 pbr==1.7.2,那麼這兩個套件無法同時安裝到全域環境中——當使用者執行
pip install package1 package2時就會發生問題。或者,如果使用者在全域環境中已經安裝了 pbr==1.8.1,而某個套件的 build-requires 為 pbr==1.7.2,那麼降級使用者的版本會相當無理。 - 它是一種公共衛生措施,旨在最大限度地提高實際準確宣告 build-dependencies 的套件數量。我們可以對套件作者寫下所有強烈的告誡,但如果建置前端預設不強制執行隔離,那麼我們不可避免地會發現 PyPI 上有許多套件只能在原始作者的機器上順利建置,而在其他環境無法建置,這是沒人需要的頭痛問題。
然而,也會有 build-requirements 在各種方面出現問題的情況。例如,套件作者可能儘管我們付出了最大努力,仍不小心遺漏了某些關鍵需求;或者,套件可能宣告了 build-requirement 為 foo >= 1.0,這在 1.0 是最新版本時運作良好,但現在 1.1 出現了一個致命錯誤;或者,使用者可能決定針對 numpy==1.7 來建置套件——覆寫套件偏好的 numpy==1.8——以確保生成的建置結果在 C ABI 層面上能與舊版本的 numpy 相容(即使這意味著生成的建置結果不受上游支援)。因此,建置前端應為使用者提供某種機制來覆寫上述預設值。例如,建置前端可以提供一個 --build-with-system-site-packages 選項,讓虛擬環境在建立時傳入 --system-site-packages 選項;或者一個 --build-requirements-override=my-requirements.txt 選項來覆寫專案的正常 build-requirements。
這裡的一般原則是,我們希望強制套件作者遵守規範,同時仍然允許終端使用者在必要時掀開引擎蓋並進行緊急修補。
樹內建置後端
在某些情況下,專案可能希望直接將建置後端的原始碼包含在原始碼樹中,而不是透過 requires 鍵值引用後端。預期會出現這種情況的兩種特定情況是:
- 後端本身想要使用自己的功能來建置自己(「自託管後端」)
- 專案特定的後端,通常由圍繞標準後端的自訂封裝程式組成,該封裝程式過於專案特定,不值得獨立分發(「樹內後端」)
專案可以透過在 pyproject.toml 中包含 backend-path 鍵值來指定後端程式碼託管於樹內。此鍵值包含一個目錄列表,前端在載入後端並執行後端掛鉤時,會將這些目錄新增到 sys.path 的開頭。
backend-path 鍵值的內容有兩個限制:
backend-path中的目錄被解釋為相對於專案根目錄,並且必須指向原始碼樹內的位置(在解析相對路徑和符號連結之後)。- 後端程式碼必須從
backend-path中指定的目錄之一載入(即不允許指定backend-path卻沒有樹內後端程式碼)。
第一個限制是為了確保原始碼樹保持自包含,且不能引用原始碼樹之外的位置。前端應檢查此條件(通常透過將位置解析為絕對路徑並解析符號連結,然後對照專案根目錄進行檢查),如果違反,則失敗並顯示錯誤訊息。
backend-path 功能旨在支援樹內後端的實作,而不是允許配置現有的後端。上述第二個限制正是為了確保這是該功能的使用方式。前端可以強制執行此檢查,但不是必須的。執行此操作通常涉及檢查後端的 __file__ 屬性與 backend-path 中的位置是否對應。
原始碼發布包
我們繼續使用舊有的 sdist 格式,並增加一些新的限制。這種格式大多未定義,但基本上歸結為:一個名為 {NAME}-{VERSION}.{EXT} 的檔案,解壓縮後成為一個稱為 {NAME}-{VERSION}/ 的可建置原始碼樹。傳統上,這些始終包含 setup.py 風格的原始碼樹;我們現在也允許它們包含 pyproject.toml 風格的原始碼樹。
整合前端要求名為 {NAME}-{VERSION}.{EXT} 的 sdist 必須能產生名為 {NAME}-{VERSION}-{COMPAT-INFO}.whl 的 Wheel。
由 PEP 517 後端建置的 sdist 的新限制如下:
- 它們將是 gzipped tar 封存檔,副檔名為
.tar.gz。目前不允許使用 Zip 封存檔或其他 tarball 壓縮格式。 - Tar 封存檔必須以現代 POSIX.1-2001 pax tar 格式建立,該格式使用 UTF-8 處理檔案名稱。
- sdist 中包含的原始碼樹預期應包含
pyproject.toml檔案。
演進說明
這裡的一個目標是盡可能簡單地將舊式 sdist 轉換為新式 sdist。(例如,這是支援動態建置需求的一個動機。)理想情況是存在一個單一的靜態 pyproject.toml,可以放入任何「0 版本」的 VCS checkout 中,將其轉換為新的樣式。這可能不是 100% 可能,但我們可以接近目標,保持對進度的追蹤很重要……因此有了這一節。
一個大概的計劃是:建立一個建置系統套件(setuptools_pypackage 或其他),知道如何對話我們想出的掛鉤語言,並將其轉換為對 setup.py 的呼叫。這可能需要對 setuptools 進行某種掛鉤或 Monkeypatch,以便在需要時提供提取 setup_requires= 引數的方法,並提供一個產生新式格式的 sdist 指令的新版本。這一切似乎是可行且足以應付大部分套件的(儘管顯然我們會在最終確定之前對這樣的系統進行原型設計)。(或者,這些變更可以直接對 setuptools 本身進行,而不是進入單獨的套件。)
但仍然存在兩個障礙,意味著我們可能無法自動將套件升級到新格式:
- 目前存在一些套件,它們堅持在執行 setup.py 之前必須在環境中安裝特定套件。這意味著如果我們決定在隔離的虛擬環境類環境中執行建置指令稿,那麼專案將需要檢查它們是否執行此操作;如果是,那麼在升級到新系統時,它們將不得不開始明確宣告這些依賴(透過
setup_requires=或pyproject.toml中的靜態宣告)。 - 目前存在一些套件,它們不宣告一致的 metadata(例如
egg_info和bdist_wheel可能會獲得不同的install_requires=)。在升級到新系統時,專案將不得不評估這是否適用於它們,如果適用,它們將需要停止這樣做。
已拒絕的選項
- 我們討論了讓 Wheel 和 sdist 掛鉤建置解壓縮後的目錄,包含與其各自封存檔相同的內容。在某些情況下,這可以避免打包和解壓縮封存檔的需要,但這似乎是過早優化。對工具而言,使用封存檔作為標準交換格式是有利的(特別是對於 Wheel,其封存格式已經標準化)。對封存檔建立的嚴格控制對於可重現的建置很重要。而且,目前尚不清楚需要解壓縮發布包的任務是否會比需要封存檔的任務更常見。
- 我們考慮了一個額外的掛鉤,在呼叫
build_wheel之前將檔案複製到建置目錄。查看現有的建置系統,我們發現將建置目錄傳入build_wheel對許多工具來說比預先將檔案複製到建置目錄更有意義。 - 將
build_wheel傳入建置目錄的想法後來也被認為是不必要的複雜化。建置工具可以在建置時使用臨時目錄或快取目錄來儲存中間檔案。如果有需要,未來可以增加一個由前端控制的快取目錄。 - 關於
build_sdist如何因預期的原因發出失敗訊號,各種選項進行了廣泛討論,包括拋出NotImplementedError以及回傳NotImplemented或None。請不要在沒有極其充分的理由下試圖重新開啟此討論,因為我們已經對此感到厭倦了。 - 允許從原始碼樹中的檔案匯入後端,會與 Python 的匯入方式更一致。然而,不允許這樣做可以防止模組名稱衝突引起的混淆錯誤。本 PEP 的初始版本沒有提供允許後端從原始碼樹內的檔案匯入的方法,但在下一個修訂版中增加了
backend-path鍵值,允許專案在需要時選擇加入此行為。
PEP 517 變更摘要
在本 PEP 的初始參考實作於 pip 19.0 發布後,對此 PEP 進行了以下變更:
- 明確禁止建置需求中的迴圈。
- 透過在
[build-system]表格中引入backend-path鍵值,增加了對樹內後端和後端自託管的支援。 - 澄清了對於未明確指定
build-backend的原始碼樹,setuptools.build_meta:__legacy__PEP 517 後端是直接呼叫setup.py的一個可接受替代方案。
附錄 A:與 PEP 516 的比較
PEP 516 是一項競爭性的建置系統介面規範提案,該提案已被拒絕,轉而支援本 PEP。主要差異在於我們的建置後端是透過基於 Python 掛鉤的介面定義,而不是基於命令列的介面。
本附錄記錄了相較於 PEP 516 支援本 PEP 的論點。
我們不期望透過指定 Python 掛鉤而非命令列介面能單獨降低呼叫後端的複雜性,因為建置前端無論如何都會希望在子處理程序中執行掛鉤——這對於隔離建置前端本身與後端程式碼,並更好地控制建置後端的執行環境非常重要。因此,在兩個提案下,都需要在 pip 中編寫一些程式碼來產生子處理程序並與某種命令列/IPC 介面通訊,並且子處理程序中需要編寫一些程式碼來了解如何解析這些命令列參數並呼叫實際的建置後端實作。因此,此圖表適用於所有提案:
+-----------+ +---------------+ +----------------+
| frontend | -spawn-> | child cmdline | -Python-> | backend |
| (pip) | | interface | | implementation |
+-----------+ +---------------+ +----------------+
這兩種方法之間的關鍵差異在於這些介面邊界如何映射到專案結構上。
.-= This PEP =-.
+-----------+ +---------------+ | +----------------+
| frontend | -spawn-> | child cmdline | -Python-> | backend |
| (pip) | | interface | | | implementation |
+-----------+ +---------------+ | +----------------+
|
|______________________________________| |
Owned by pip, updated in lockstep |
|
|
PEP-defined interface boundary
Changes here require distutils-sig
.-= Alternative =-.
+-----------+ | +---------------+ +----------------+
| frontend | -spawn-> | child cmdline | -Python-> | backend |
| (pip) | | | interface | | implementation |
+-----------+ | +---------------+ +----------------+
|
| |____________________________________________|
| Owned by build backend, updated in lockstep
|
PEP-defined interface boundary
Changes here require distutils-sig
透過將 PEP 定義的介面邊界移入 Python 程式碼,我們獲得了三個關鍵優勢。
第一,由於建置前端數量可能很少(pip,還有……也許還有幾個?),而自訂建置後端的長尾效應將會很大(因為它們由每個套件分別選擇以匹配其特定的建置需求),因此實際的圖表可能看起來更像:
.-= This PEP =-.
+-----------+ +---------------+ +----------------+
| frontend | -spawn-> | child cmdline | -Python+> | backend |
| (pip) | | interface | | | implementation |
+-----------+ +---------------+ | +----------------+
|
| +----------------+
+> | backend |
| | implementation |
| +----------------+
:
:
.-= Alternative =-.
+-----------+ +---------------+ +----------------+
| frontend | -spawn+> | child cmdline | -Python-> | backend |
| (pip) | | | interface | | implementation |
+-----------+ | +---------------+ +----------------+
|
| +---------------+ +----------------+
+> | child cmdline | -Python-> | backend |
| | interface | | implementation |
| +---------------+ +----------------+
:
:
也就是說,本 PEP 導致整個生態系統中的程式碼總量更少。特別是,它降低了建立新建置系統的准入門檻。例如,這是一個完整、可運作的建置後端:
# mypackage_custom_build_backend.py
import os.path
import pathlib
import shutil
import tarfile
SDIST_NAME = "mypackage-0.1"
SDIST_FILENAME = SDIST_NAME + ".tar.gz"
WHEEL_FILENAME = "mypackage-0.1-py2.py3-none-any.whl"
#################
# sdist creation
#################
def _exclude_hidden_and_special_files(archive_entry):
"""Tarfile filter to exclude hidden and special files from the archive"""
if archive_entry.isfile() or archive_entry.isdir():
if not os.path.basename(archive_entry.name).startswith("."):
return archive_entry
def _make_sdist(sdist_dir):
"""Make an sdist and return both the Python object and its filename"""
sdist_path = pathlib.Path(sdist_dir) / SDIST_FILENAME
sdist = tarfile.open(sdist_path, "w:gz", format=tarfile.PAX_FORMAT)
# Tar up the whole directory, minus hidden and special files
sdist.add(os.getcwd(), arcname=SDIST_NAME,
filter=_exclude_hidden_and_special_files)
return sdist, SDIST_FILENAME
def build_sdist(sdist_dir, config_settings):
"""PEP 517 sdist creation hook"""
sdist, sdist_filename = _make_sdist(sdist_dir)
return sdist_filename
#################
# wheel creation
#################
def get_requires_for_build_wheel(config_settings):
"""PEP 517 wheel building dependency definition hook"""
# As a simple static requirement, this could also just be
# listed in the project's build system dependencies instead
return ["wheel"]
def build_wheel(wheel_directory,
metadata_directory=None, config_settings=None):
"""PEP 517 wheel creation hook"""
from wheel.archive import archive_wheelfile
path = os.path.join(wheel_directory, WHEEL_FILENAME)
archive_wheelfile(path, "src/")
return WHEEL_FILENAME
當然,這是一個糟糕的建置後端:它要求使用者手動設定 src/mypackage-0.1.dist-info/ 中的 Wheel metadata;當版本號變更時,必須在多個地方手動更新……但它確實有效,且可以增量增加更多功能。許多經驗表明,大型成功的專案往往起源於快速破解(例如,Linux ——「只是一個愛好,不會很大很專業」;IPython/Jupyter —— 研究生的 $PYTHONSTARTUP 檔案),因此如果我們的目標是鼓勵開發優秀建置工具的活躍生態系統,那麼最大限度地降低准入門檻就非常重要。
第二,因為 Python 提供了更簡單且更豐富的結構來描述介面,我們消除了規範中不必要的複雜性——而規範是複雜性最糟糕的存放地,因為變更規範需要各個相關方之間痛苦的共識建立。在命令列介面方法中,我們必須想出 ad hoc 的方法將多種不同類型的輸入對映到單一線性命令列(例如我們如何避免使用者指定的配置引數與 PEP 定義的引數之間的衝突?我們如何指定可選引數?在使用 Python 介面時,這些問題有簡單、明顯的答案)。在產生和管理子處理程序時,有許多細節必須正確處理,微妙的跨平台差異,以及一些最明顯的方法——例如,使用 stdout 回傳 build_requires 操作的資料——可能會產生意想不到的陷阱(例如,當計算建置需求需要產生一些子處理程序,而這些子處理程序偶爾會將錯誤訊息列印到 stdout 時會發生什麼?顯然,謹慎的建置後端作者可以避免此問題,但定義 Python 介面的最明顯方式完全消除了這種可能性,因為掛鉤的回傳值界限清晰)。
總體而言,需要將建置後端隔離到它們自己的處理程序中意味著我們無法完全消除 IPC 複雜性——但透過將 IPC 通道的兩側置於單一專案的控制之下,使得修復 IPC 介面中的錯誤比如果修復錯誤需要生態系統中多個方面的協調一致與變更要便宜得多。
第三,且最重要的是,Python 掛鉤方法為我們在未來演進此規範提供了更強大的選擇。
具體來說,想像明年我們增加了一個新的 build_sdist_from_vcs 掛鉤,它提供了當前 build_sdist 掛鉤的替代方案,其中前端負責將版本控制追蹤 metadata 傳遞給後端(包括指出何時追蹤所有磁碟上的檔案),而不是個別後端必須自己查詢該資訊。為了管理過渡,我們會希望建置前端能在可用時透明地使用 build_sdist_from_vcs,否則回退到 build_sdist;並且我們希望建置後端能夠定義這兩種方法,以保持與舊版和新版建置前端的相容性。
此外,我們的機制還應滿足另外兩個目標:(a) 如果新版本的例如 pip 和 flit 都更新以支援新介面,那麼這應該足以讓它被使用;特別是,不應該要求每個使用 flit 的專案都更新其個人的 pyproject.toml 檔案。(b) 我們不希望為了進行這種協商而產生額外的處理程序,因為處理程序產生在某些平台(Windows)上部署大型多套件堆疊時很容易成為瓶頸。
在本文描述的介面中,所有這些目標都很容易實現。因為 pip 控制在子處理程序中執行的程式碼,它可以很容易地編寫如下內容:
command, backend, args = parse_command_line_args(...)
if command == "build_sdist":
if hasattr(backend, "build_sdist_from_vcs"):
backend.build_sdist_from_vcs(...)
elif hasattr(backend, "build_sdist"):
backend.build_sdist(...)
else:
# error handling
在將公共介面邊界放在子處理程序呼叫的替代方案中,這是不可能的——要麼我們需要產生一個額外的處理程序來查詢支援哪些介面(如 PEP 516 的早期草案所包含的,本文的替代方案),要麼我們完全放棄自動協商(如該 PEP 的當前版本),這意味著介面的任何變更都將要求 N 個個別專案在任何變更生效前更新其 pyproject.toml 檔案,並且任何變更必然僅限於新版本。
這的一個具體後果是,在本 PEP 中,我們能夠使 prepare_metadata_for_build_wheel 指令成為可選的。在我們的設計中,這可以由建置前端輕易處理,它們可以在處理程序執行器中放置如下程式碼:
def dump_wheel_metadata(backend, working_dir):
"""Dumps wheel metadata to working directory.
Returns absolute path to resulting metadata directory
"""
if hasattr(backend, "prepare_metadata_for_build_wheel"):
subdir = backend.prepare_metadata_for_build_wheel(working_dir)
else:
wheel_fname = backend.build_wheel(working_dir)
already_built = os.path.join(working_dir, "ALREADY_BUILT_WHEEL")
with open(already_built, "w") as f:
f.write(wheel_fname)
subdir = unzip_metadata(os.path.join(working_dir, wheel_fname))
return os.path.join(working_dir, subdir)
def ensure_wheel_is_built(backend, output_dir, working_dir, metadata_dir):
"""Ensures built wheel is available in output directory
Returns absolute path to resulting wheel file
"""
already_built = os.path.join(working_dir, "ALREADY_BUILT_WHEEL")
if os.path.exists(already_built):
with open(already_built, "r") as f:
wheel_fname = f.read().strip()
working_path = os.path.join(working_dir, wheel_fname)
final_path = os.path.join(output_dir, wheel_fname)
os.rename(working_path, final_path)
os.remove(already_built)
else:
wheel_fname = backend.build_wheel(output_dir, metadata_dir=metadata_dir)
return os.path.join(output_dir, wheel_fname)
從而向前端的其餘部分公開一個完全統一的介面,沒有額外的子處理程序呼叫,沒有重複的建置等。但顯然,這是您只想作為私有的、專案內介面一部分編寫的程式碼類型(例如,給定的範例要求工作目錄在兩次呼叫之間共享,但不能與任何其他 Wheel 建置共享,並且 metadata 輔助函式的回傳值將傳遞回 Wheel 建置函式)。
(當然,使 metadata 指令成為可選的是降低開發新後端准入門檻的一種方式,如上所述。)
其他差異
除了上述主要的命令列與 Python 掛鉤差異之外,本提案還有一些其他差異:
- Metadata 指令是可選的(如上所述)。
- 我們將 metadata 回傳為一個目錄,而不是單一的 METADATA 檔案。這更符合實際情況中 Wheel metadata 分布在多個檔案(例如入口點)中的方式,並在未來給予我們更多選擇。(例如,與其遵循 PEP 426 提議將 METADATA 格式切換為 JSON,我們可能會決定保持現有的 METADATA 不變以實現向後相容,同時以 JSON「附屬」檔案的形式在同一個目錄內增加新擴充功能。或者不這樣做;重點是這讓我們的選擇更開放。)
- 我們提供了一種在 metadata 步驟和 Wheel 建置步驟之間傳遞資訊的機制。我想每個人可能都會同意這是一個好主意?
- 我們提供了關於建置環境的更詳細建議,但這些無論如何都不是規範性的。
版權
此文件已歸入公有領域 (public domain)。
來源:https://github.com/python/peps/blob/main/peps/pep-0517.rst