> 作者:[Tarek Ziadé](http://www.aosabook.org/en/intro1.html#ziade-tarek),翻譯:張吉
> 原文:[http://www.aosabook.org/en/packaging.html](http://www.aosabook.org/en/packaging.html)
## 14.1 簡介
對于如何安裝軟件,目前有兩種思想流派。第一種是說軟件應該自給自足,不依賴于其它任何部件,這點在Windows和Mac OS X系統中很流行。這種方式簡化了軟件的管理:每個軟件都有自己獨立的“領域”,安裝和卸載它們不會對操作系統產生影響。如果軟件依賴一項不常見的類庫,那么這個類庫一定是包含在軟件安裝包之中的。
第二種流派,主要在類Linux的操作系統中盛行,即軟件應該是由一個個獨立的、小型的軟件包組成的。類庫被包含在軟件包中,包與包之間可以有依賴關系。安裝軟件時需要查找和安裝它所依賴的其他特定版本的軟件包。這些依賴包通常是從一個包含所有軟件包的中央倉庫中獲取的。這種理念也催生了Linux發行版中那些復雜的依賴管理工具,如`dpkg`和`RPM`。它們會跟蹤軟件包的依賴關系,并防止兩個軟件使用了版本相沖突的第三方包。
以上兩種流派各有優劣。高度模塊化的系統可以使得更新和替換某個軟件包變的非常方便,因為每個類庫都只有一份,所有依賴于它的應用程序都能因此受益。比如,修復某個類庫的安全漏洞可以立刻應用到所有程序中,而如果應用程序使用了自帶的類庫,那安全更新就很難應用進去了,特別是在類庫版本不一致的情況下更難處理。
不過這種“模塊化”也被一些開發者視為缺點,因為他們無法控制應用程序的依賴關系。他們希望提供一個獨立和穩定的軟件運行環境,這樣就不會在系統升級后遭遇各種依賴方面的問題。
在安裝程序中包含所有依賴包還有一個優點:便于跨平臺。有些項目在這點上做到了極致,它們將所有和操作系統的交互都封裝了起來,在一個獨立的目錄中運行,甚至包括日志文件的記錄位置。
Python的打包系統使用的是第二種設計思想,并盡可能地方便開發者、管理員、用戶對軟件的管理。不幸的是,這種方式導致了種種問題:錯綜復雜的版本結構、混亂的數據文件、難以重新打包等等。三年前,我和其他一些Python開發者決定研究解決這個問題,我們自稱為“打包別動隊”,本文就是講述我們在這個問題上做出的努力和取得的成果。
### 術語
在Python中,?*包*?表示一個包含Python文件的目錄。Python文件被稱為?*模塊*?,這樣一來,使用“包”這個單詞就顯得有些模糊了,因為它常常用來表示某個項目的?*發行版本*?。
Python開發者有時也對此表示不能理解。為了更清晰地進行表述,我們用“Python包(package)”來表示一個包含Python文件的目錄,用“發行版本(release)”來表示某個項目的特定版本,用“發布包(distribution)”來表示某個發行版本的源碼或二進制文件,通常是Tar包或Zip文件的形式。
## 14.2 Python開發者的困境
大多數Python開發者希望自己的程序能夠在任何環境中運行。他們還希望自己的軟件既能使用標準的Python類庫,又能使用依賴于特定系統類型的類庫。但除非開發者使用現有的各種打包工具生成不同的軟件包,否則他們打出的軟件安裝包就必須在一個安裝有Python環境的系統中運行。這樣的軟件包還希望做到以下幾點:
* 其他人可以針對不同的目標系統對這個軟件重新打包;
* 軟件所依賴的包也能夠針對不同的目標系統進行重新打包;
* 系統依賴項能夠被清晰地描述出來。
要做到以上幾點往往是不可能的。舉例來說,Plone這一功能全面的CMS系統,使用了上百個純Python語言編寫的類庫,而這些類庫并不一定在所有的打包系統中提供。這就意味著Plone必須將它所依賴的軟件包都集成到自己的安裝包中。要做到這一點,他們選擇使用`zc.buildout`這一工具,它能夠將所有的依賴包都收集起來,生成一個完整的應用程序文件,在獨立的目錄中運行。它事實上是一個二進制的軟件包,因為所有C語言代碼都已經編譯好了。
這對開發者來說是福音:他們只需要描述好依賴關系,然后借助`zc.buildout`來發布自己的程序即可。但正如上文所言,這種發布方式在系統層面構筑了一層屏障,這讓大多數Linux系統管理員非常惱火。Windows管理員不會在乎這些,但CentOS和Debian管理員則會,因為按照他們的管理原則,系統中的所有文件都應該被注冊和歸類到現有的管理工具中。
這些管理員會想要將你的軟件按照他們自己的標準重新打包。問題在于:Python有沒有這樣的打包工具,能夠自動地按照新的標準重新打包?如果有,那么Python的任何軟件和類庫就能夠針對不同的目標系統進行打包,而不需要額外的工作。這里,“自動”一詞并不是說打包過程可以完全由腳本來完成——這點上`RPM`和`dpkg`的使用者已經證實是不可能的了,因為他們總會需要增加額外的信息來重新打包。他們還會告訴你,在重新打包的過程中會遇到一些開發者沒有遵守基本打包原則的情況。
我們來舉一個實際例子,如何通過使用現有的Python打包工具來惹惱那些想要重新打包的管理員:在發布一個名為“MathUtils”的軟件包時使用“Fumanchu”這樣的版本號名字。撰寫這個類庫的數學家想用自家貓咪的名字來作為版本號,但是管理員怎么可能知道“Fumanchu”是他家第二只貓的名字,第一只貓叫做“Phil”,所以“Fumanchu”版本要比“Phil”版本來得高?
可能這個例子有些極端,但是在現有的打包工具和規范中是可能發生的。最壞的情況是`easy_install`和`pip`使用自己的一套標準來追蹤已安裝的文件,并使用字母順序來比較“Fumanchu”和“Phil”的版本高低。
另一個問題是如何處理數據文件。比如,如果你的軟件使用了SQLite數據庫,安裝時被放置在包目錄中,那么在程序運行時,系統會阻止你對其進行讀寫操作。這樣做還會破壞Linux系統的一項慣例,即`/var`目錄下的數據文件是需要進行備份的。
在現實環境中,系統管理員需要能夠將你的文件放置到他們想要的地方,并且不破壞程序的完整性,這就需要你來告訴他們各類文件都是做什么用的。讓我們換一種方式來表述剛才的問題:Python是否有這樣一種打包工具,它可以提供各類信息,足以讓第三方打包工具能據此重新進行打包,而不需要閱讀軟件的源碼?
## 14.3 現有的打包管理架構
Python標準庫中提供的`Distutils`打包工具充斥了上述的種種問題,但由于它是一種標準,所以人們要么繼續忍受并使用它,或者轉向更先進的工具`Setuptools`,它在Distutils之上提供了一些高級特性。另外還有`Distribute`,它是`Setuptools`的衍生版本。`Pip`則是一種更為高級的安裝工具,它依賴于`Setuptools`。
但是,這些工具都源自于`Distutils`,并繼承了它的種種問題。有人也想過要改進`Distutils`本身,但是由于它的使用范圍已經很廣很廣,任何小的改動都會對Python軟件包的整個生態系統造成沖擊。
所以,我們決定凍結`Distutils`的代碼,并開始研發`Distutils2`,不去考慮向前兼容的問題。為了解釋我們所做的改動,首先讓我們近距離觀察一下`Distutils`。
### 14.3.1 Distutils基礎及設計缺陷
`Distutils`由一些命令組成,每條命令都是一個包含了`run`方法的類,可以附加若干參數進行調用。`Distutils`還提供了一個名為`Distribution`的類,它包含了一些全局變量,可供其他命令使用。
當要使用`Distutils`時,Python開發者需要在項目中添加一個模塊,通常命名為`setup.py`。這個模塊會調用`Distutils`的入口函數:`setup`。這個函數有很多參數,這些參數會被`Distribution`實例保存起來,供后續使用。下面這個例子中我們指定了一些常用的參數,如項目名稱和版本,它所包含的模塊等:
~~~
from distutils.core import setup
setup(name='MyProject', version='1.0', py_modules=['mycode.py'])
~~~
這個模塊可以用來執行`Distutils`的各種命令,如`sdist`。這條命令會在`dist`目錄中創建一個源代碼發布包:
~~~
$ python setup.py sdist
~~~
這個模塊還可以執行`install`命令:
~~~
$ python setup.py install
~~~
`Distutils`還提供了一些其他命令:
* `upload`?將發布包上傳至在線倉庫
* `register`?向在線倉庫注冊項目的基本信息,而不上傳發布包
* `bdist`?創建二進制發布包
* `bdist_msi`?創建`.msi`安裝包,供Windows系統使用
我們還可以使用其他一些命令來獲取項目的基本信息。
所以在安裝或獲取應用程序信息時都是通過這個文件調用`Distutils`實現的,如獲取項目名稱:
~~~
$ python setup.py --name
MyProject
~~~
`setup.py`是一個項目的入口,可以通過它對項目進行構建、打包、發布、安裝等操作。開發者通過這個函數的參數信息來描述自己的項目,并使用它進行各種打包任務。這個文件同樣用于在目標系統中安裝軟件。

圖14.1 安裝
然而,使用同一個文件來對項目進行打包、發布、以及安裝,是`Distutils`的主要缺點。例如,你需要查看`lxml`項目的名稱屬性,`setup.py`會執行很多其他無關的操作,而不是簡單返回一個字符串:
~~~
$ python setup.py --name
Building lxml version 2.2.
NOTE: Trying to build without Cython, pre-generated 'src/lxml/lxml.etree.c'
needs to be available.
Using build configuration of libxslt 1.1.26
Building against libxml2/libxslt in the following directory: /usr/lib/lxml
~~~
在有些項目中它甚至會執行失敗,因為開發者默認為`setup.py`只是用來安裝軟件的,而其他一些`Distutils`功能只在開發過程中使用。因此,`setup.py`的角色太多,容易引起他人的困惑。
### 14.3.2 元信息和PyPI
`Distutils`在構建發布包時會創建一個`Metadata`文件。這個文件是按照PEP3141編寫的,包含了一些常見的項目信息,包括名稱、版本等,主要有以下幾項:
* `Name`:項目名稱
* `Version`:發布版本號
* `Summary`:項目簡介
* `Description`:項目詳情
* `Home-Page`:項目主頁
* `Author`:作者
* `Classifers`:項目類別。Python為不同的發行協議、發布版本(beta,alpha,final)等提供了不同的類別。
* `Requires`,`Provides`,`Obsoletes`:描述項目依賴信息
這些信息一般都能移植到其他打包系統中。
Python項目索引(Python Package Index,簡稱PyPI2),是一個類似CPAN的中央軟件包倉庫,可以調用`Distutils`的`register`和`upload`命令來注冊和發布項目。`register`命令會構建`Metadata`文件并傳送給PyPI,讓訪問者和安裝工具能夠瀏覽和搜索。

圖14.2:PyPI倉庫
你可以通過`Classifies`(類別)來瀏覽,獲取項目作者的名字和主頁。同時,`Requires`可以用來定義Python模塊的依賴關系。`requires`選項可以向元信息文件的`Requires`字段添加信息:
~~~
from distutils.core import setup
setup(name='foo', version='1.0', requires=['ldap'])
~~~
這里聲明了對`ldap`模塊的依賴,這種依賴并沒有實際效力,因為沒有安裝工具會保證這個模塊真實存在。如果說Python代碼中會使用類似Perl的`require`關鍵字來定義依賴關系,那還有些作用,因為這時安裝工具會檢索PyPI上的信息并進行安裝,其實這也就是CPAN的做法。但是對于Python來說,`ldap`模塊可以存在于任何項目之中,因為`Distutils`是允許開發者發布一個包含多個模塊的軟件的,所以這里的元信息字段并無太大作用。
`Metadata`的另一個缺點是,因為它是由Python腳本創建的,所以會根據腳本執行環境的不同而產生特定信息。比如,運行在Windows環境下的一個項目會在`setup.py`文件中有以下描述:
~~~
from distutils.core import setup
setup(name='foo', version='1.0', requires=['win32com'])
~~~
這樣配置相當于是默認該項目只會運行在Windows環境下,即使它可能提供了跨平臺的方案。一種解決方法是根據不同的平臺來指定`requires`參數:
~~~
from distutils.core import setup
import sys
if sys.platform == 'win32':
setup(name='foo', version='1.0', requires=['win32com'])
else:
setup(name='foo', version='1.0')
~~~
但這種做法往往會讓事情更糟。要注意,這個腳本是用來將項目的源碼包發布到PyPI上的,這樣寫就說明它向PyPI上傳的`Metadata`文件會因為該腳本運行環境的不同而不同。換句話說,這使得我們無法在元信息文件中看出這個項目依賴于特定的平臺。
### 14.3.3 PyPI的架構設計

圖14.3 PyPI工作流
如上文所述,PyPI是一個Python項目的中央倉庫,人們可以通過不同的類別來搜索已有的項目,也可以創建自己的項目。人們可以上傳項目源碼和二進制文件,供其他人下載使用或研究。同時,PyPI還提供了相應的Web服務,讓安裝工具可以調用它來檢索和下載文件。
#### 注冊項目并上傳發布包
我們可以使用`Distutils`的`register`命令在PyPI中注冊一個項目。這個命令會根據項目的元信息生成一個POST請求。該請求會包含驗證信息,PyPI使用HTTP基本驗證來確保所有的項目都和一個注冊用戶相關聯。驗證信息保存在`Distutils`的配置文件中,或在每次執行`register`命令時提示用戶輸入。以下是一個使用示例:
~~~
$ python setup.py register
running register
Registering MPTools to http://pypi.python.org/pypi
Server response (200): OK
~~~
每個注冊項目都會產生一個HTML頁面,上面包含了它的元信息。開發者可以使用`upload`命令將發布包上傳至PyPI:
~~~
$ python setup.py sdist upload
running sdist
…
running upload
Submitting dist/mopytools-0.1.tar.gz to http://pypi.python.org/pypi
Server response (200): OK
~~~
如果開發者不想將代碼上傳至PyPI,可以使用元信息中的`Download-URL`屬性來指定一個外部鏈接,供用戶下載。
#### 檢索PyPI
除了在頁面中檢索項目,PyPI還提供了兩個接口供程序調用:簡單索引協議和XML-PRC API。
簡單索引協議的地址是`http://pypi.python.org/simple/`,它包含了一個鏈接列表,指向所有的注冊項目:
~~~
<html><head><title>Simple Index</title></head><body>
? ? ?
<a href='MontyLingua/'>MontyLingua</a><br/>
<a href='mootiro_web/'>mootiro_web</a><br/>
<a href='Mopidy/'>Mopidy</a><br/>
<a href='mopowg/'>mopowg</a><br/>
<a href='MOPPY/'>MOPPY</a><br/>
<a href='MPTools/'>MPTools</a><br/>
<a href='morbid/'>morbid</a><br/>
<a href='Morelia/'>Morelia</a><br/>
<a href='morse/'>morse</a><br/>
? ? ?
</body></html>
~~~
如MPTools項目對應的`MPTools/`目錄,它所指向的路徑會包含以下內容:
* 所有發布包的地址
* 在`Metadata`中定義的項目網站地址,包含所有版本
* 下載地址(`Download-URL`),同樣包含所有版本
以MPTools項目為例:
~~~
<html><head><title>Links for MPTools</title></head>
<body><h1>Links for MPTools</h1>
<a href="../../packages/source/M/MPTools/MPTools-0.1.tar.gz">MPTools-0.1.tar.gz</a><br/>
<a href="http://bitbucket.org/tarek/mopytools" rel="homepage">0.1 home_page</a><br/>
</body></html>
~~~
安裝工具可以通過訪問這個索引來查找項目的發布包,或者檢查`http://pypi.python.org/simple/PROJECT_NAME/`是否存在。
但是,這個協議主要有兩個缺陷。首先,PyPI目前還是單臺服務器。雖然很多用戶會自己搭建鏡像,但過去兩年中曾發生過幾次PyPI無法訪問的情況,用戶無法下載依賴包,導致項目構建出現問題。比如說,在構建一個Plone項目時,需要向PyPI發送近百次請求。所以PyPI在這里成為了單點故障。
其次,當項目的發布包沒有保存在PyPI中,而是通過`Download-URL`指向了其他地址,安裝工具就需要重定向到這個地址下載發布包。這種情況也會增加安裝過程的不穩定性。
簡單索引協議只是提供給安裝工具一個項目列表,并不包含項目元信息。可以通過PyPI的XML-RPC API來獲取項目元信息:
~~~
>>> import xmlrpclib
>>> import pprint
>>> client = xmlrpclib.ServerProxy('http://pypi.python.org/pypi')
>>> client.package_releases('MPTools')
['0.1']
>>> pprint.pprint(client.release_urls('MPTools', '0.1'))
[{'comment_text': &rquot;,
'downloads': 28,
'filename': 'MPTools-0.1.tar.gz',
'has_sig': False,
'md5_digest': '6b06752d62c4bffe1fb65cd5c9b7111a',
'packagetype': 'sdist',
'python_version': 'source',
'size': 3684,
'upload_time': <DateTime '20110204T09:37:12' at f4da28>,
'url': 'http://pypi.python.org/packages/source/M/MPTools/MPTools-0.1.tar.gz'}]
>>> pprint.pprint(client.release_data('MPTools', '0.1'))
{'author': 'Tarek Ziade',
'author_email': 'tarek@mozilla.com',
'classifiers': [],
'description': 'UNKNOWN',
'download_url': 'UNKNOWN',
'home_page': 'http://bitbucket.org/tarek/mopytools',
'keywords': None,
'license': 'UNKNOWN',
'maintainer': None,
'maintainer_email': None,
'name': 'MPTools',
'package_url': 'http://pypi.python.org/pypi/MPTools',
'platform': 'UNKNOWN',
'release_url': 'http://pypi.python.org/pypi/MPTools/0.1',
'requires_python': None,
'stable_version': None,
'summary': 'Set of tools to build Mozilla Services apps',
'version': '0.1'}
~~~
這種方式的問題在于,項目元信息原本就能以靜態文件的方式在簡單索引協議中提供,這樣可以簡化安裝工具的復雜性,也可以減少PyPI服務的請求數。對于諸如下載數量這樣的動態數據,可以在其他接口中提供。用兩種服務來獲取所有的靜態內容,顯然不太合理。
### 14.3.4 Python安裝目錄的結構
在使用`python setup.py install`安裝一個Python項目后,`Distutils`這一Python核心類庫會負責將程序代碼復制到目標系統的相應位置。
* *Python包*?和模塊會被安裝到Python解釋器程序所在的目錄中,并隨解釋器啟動:Ubuntu系統中會安裝到`/usr/local/lib/python2.6/dist-packages/`,Fedora則是`/usr/local/lib/python2.6/sites-packages/`。
* 項目中的?*數據文件*?可以被安裝到任何位置。
* *可執行文件*?會被安裝到系統的`bin`目錄下,依平臺類型而定,可能是`/usr/local/bin`,或是其它指定的目錄。
從Python2.5開始,項目的元信息文件會隨模塊和包一起發布,名稱為`project-version.egg-info`。比如,`virtualenv`項目會有一個`virtualenv-1.4.9.egg-info`文件。這些元信息文件可以被視為一個已安裝項目的數據庫,因為可以通過遍歷其中的內容來獲取已安裝的項目和版本。但是,`Distutils`并沒有記錄項目所安裝的文件列表,也就是說,我們無法徹底刪除安裝某個項目后產生的所有文件。可惜的是,`install`命令本身是提供了一個名為`--record`的參數的,可以將已安裝的文件列表記錄在文本文件中,但是這個參數并沒有默認開啟,而且`Distutils`的文檔中幾乎沒有提及這個參數。
### 14.3.5 Setuptools、Pip等工具
正如介紹中所提到的,有些項目已經在嘗試修復`Distutils`的問題,并取得了一些成功。
#### 依賴問題
PyPI允許開發者在發布的項目中包含多個模塊,還允許項目通過定義`Require`屬性來聲明模塊級別的依賴。這兩種做法都是合理的,但是同時使用就會很糟糕。
正確的做法應該是定義項目級別的依賴,這也是`Setuptools`在`Distutils`之上附加的一個特性。它還提供了一個名為`easy_install`的腳本來從PyPI上自動獲取和安裝依賴項。在實際生產中,模塊級別的依賴并沒有真正被使用,更多人傾向于使用`Setuptools`。然而,這些特性只是針對`Setuptools`的,并沒有被`Distutils`或PyPI所接受,所以`Setuptools`實質上是一個構建在錯誤設計上的仿冒品。
`easy_install`需要下載項目的壓縮文檔,執行`setup.py`來獲取元信息,并對每個依賴項進行相同的操作。項目的依賴樹會隨著軟件包的下載逐步勾畫出來。
雖然PyPI上可以直接瀏覽項目元信息,但是`easy_install`還是需要下載所有的軟件包,因為上文提到過,PyPI上的項目元信息很可能和上傳時所使用的平臺有關,從而和目標系統有所差異。但是這種一次性安裝項目依賴的做法已經能夠解決90%的問題了,的確是個很不錯的特性。這也是為什么`Setuptools`被廣泛采用的原因。然而,它還是有以下一些問題:
* 如果某一個依賴項安裝失敗,它并沒有提供回滾的選項,因此系統會處于一個不可用的狀態。
* 項目依賴樹是在安裝一個個軟件包時構建出來的,因此當其中兩個依賴項產生沖突時,系統也會變的不可用。
#### 卸載的問題
雖然`Setuptools`可以在元信息中記錄已安裝的文件,但它并沒有提供卸載功能。另一個工具`Pip`,它通過擴展`Setuptools`的元信息來記錄已安裝的文件,從而能夠進行卸載操作。但是,這組信息又是一種自定義的內容,因此一個Python項目很可能包含四種不同的元信息:
* `Distutils`的`egg-info`,一個單一的文件;
* `Setuptools`的`egg-info`,一個目錄,記錄了`Setuptools`特定的元信息;
* `Pip`的`egg-info`,是后者的擴展;
* 其它由打包系統產生的信息。
### 14.3.6 數據文件如何處理?
在`Distutils`中,數據文件可以被安裝在任意位置。你可以像這樣在`setup.py`中定義一個項目的數據文件:
~~~
setup(…,
packages=['mypkg'],
package_dir={'mypkg': 'src/mypkg'},
package_data={'mypkg': ['data/*.dat']},
)
~~~
那么,`mypkg`項目中所有以`.dat`為擴展名的文件都會被包含在發布包中,并隨Python代碼安裝到目標系統。
對于需要安裝到項目目錄之外的數據文件,可以進行如下配置。他們隨項目一起打包,并安裝到指定的目錄中:
~~~
setup(…,
data_files=[('bitmaps', ['bm/b1.gif', 'bm/b2.gif']),
('config', ['cfg/data.cfg']),
('/etc/init.d', ['init-script'])]
)
~~~
這對系統打包人員來說簡直是噩夢:
* 元信息中并不包含數據文件的信息,因此打包人員需要閱讀`setup.py`文件,甚至是研究項目源碼來獲取這些信息。
* 不應該由開發人員來決定項目數據文件應該安裝到目標系統的哪個位置。
* 數據文件沒有區分類型,圖片、幫助文件等都被視為同等來處理。
打包人員在對項目進行打包時只能去根據目標系統的情況來修改`setup.py`文件,從而讓軟件包能夠順利安裝。要做到這一點,他就需要閱讀程序代碼,修改所有用到這些文件的地方。`Setuptools`和`Pip`并沒有解決這一問題。
## 14.4 改進標準
所以最后我們得到的是這樣一個打包系統:所有功能都由一個模塊提供,項目的元信息不完整,無法描述清楚項目中包含的所有內容。現在就讓我們來做些改進。
### 14.4.1 元信息
首先,我們要修正`Metadata`標準中的內容。PEP 345定義了一個新的標準,它包含以下內容:
* 更合理的版本定義方式
* 項目級別的依賴關系
* 使用一種靜態的方式描述平臺相關的屬性
#### 版本
元信息標準的目標之一是能夠讓Python包管理工具使用相同的方式來對項目進行分類。對于版本號來說,應該讓所有的工具都知道“1.1”是在“1.0”之后的。如果項目使用了自己定義的版本號命名方式,就無法做到這一點了。
要保證這種一致性,唯一的方法是讓所有的項目都按照統一的方式來命名版本號。我們選擇的方式是經典的序列版本號,在PEP 386中定義,它的格式是:
~~~
N.N[.N]+[{a|b|c|rc}N[.N]+][.postN][.devN]
~~~
其中:
* *N*?是一個整數。你可以使用任意數量的N,用點號將它們分隔開來。但至少要有兩個N,即“主版本.次版本”。
* *a, b, c*?分別是?*alpha, beta, release candidate*?的簡寫,它們后面還有一個整數。預發布版本有兩種標記,c和rc,主要是為了和過去兼容,但c更簡單些。
* *dev*?加一個數字表示開發版本。
* *post*?加一個數字表示已發布版本。
根據項目發布周期的不同,開發版本和已發布版本可以作為兩個最終版本之間的過渡版本號。大多數項目會使用開發版本。
按照這個形式,PEP 386定義了嚴格的順序:
* alpha < beta < rc < final
* dev < non-dev < post, non-dev包括alpha, beta, rc或者final
以下是一個完整的示例:
~~~
1.0a1 < 1.0a2.dev456 < 1.0a2 < 1.0a2.1.dev456
< 1.0a2.1 < 1.0b1.dev456 < 1.0b2 < 1.0b2.post345
< 1.0c1.dev456 < 1.0c1 < 1.0.dev456 < 1.0
< 1.0.post456.dev34 < 1.0.post456
~~~
這樣定義的目標在于讓其他打包系統能夠將Python項目的版本號方便地轉換成它們自己的版本命名規則。目前,如果上傳的項目使用了PEP 345定義的元信息,PyPI會拒絕接受沒有遵守PEP 386版本號命名規范的項目。
#### 依賴
PEP 345定義了三個新的元信息屬性,用來替換PEP 314中的`Requires`,`Provides`,和`Obsoletes`,它們是`Requires-Dist`,`Provides-Dist`,`Obsoletes-Dist`。這些屬性可以在元信息中出現多次。
`Requires-Dist`中定義了項目所依賴的軟件包,使用依賴項目的`Name`元信息,并可以跟上一個版本號。這些依賴項目的名稱必須能在PyPI中找到,且版本號命名規則要遵守PEP 386中的定義。以下是一些示例:
~~~
Requires-Dist: pkginfo
Requires-Dist: PasteDeploy
Requires-Dist: zope.interface (>3.5.0)
~~~
`Provides-Dist`用來定義項目中包含的其他項目,常用于合并兩個項目的情形。比如,ZODB項目可以包含名為`transaction`的項目,并聲明:
~~~
Provides-Dist: transaction
~~~
`Obsoletes-Dist`主要用于將其它項目標記為本項目的過期版本。
~~~
ObsoletesDist: OldName
~~~
#### 環境標識
環境標識可以添加在上述三個屬性的后面,使用分號分隔,用來標識該屬性在什么樣的目標環境中生效。以下是一些示例:
~~~
Requires-Dist: pywin32 (>1.0); sys.platform == 'win32'
Obsoletes-Dist: pywin31; sys.platform == 'win32'
Requires-Dist: foo (1,!=1.3); platform.machine == 'i386'
Requires-Dist: bar; python_version == '2.4' or python_version == '2.5'
Requires-External: libxslt; 'linux' in sys.platform
~~~
這種簡易的語法足以讓非Python程序員看懂:它使用`==`或`in`運算符(含`!=`和`not in`),且可以通過邏輯運算符連接。PEP 345中規定以下屬性可以使用這種語法:
* `Requires-Python`
* `Requires-External`
* `Requires-Dist`
* `Provides-Dist`
* `Obsoletes-Dist`
* `Classifier`
### 14.4.2 用戶安裝了什么?
出于互通性的考慮,Python項目的安裝格式必須一致。要讓安裝工具A能夠檢測到工具B安裝的項目,它們就必須共享和更新相同的項目列表。
當然,理想中用戶會在系統中只使用一種安裝工具,但是他們也許會需要遷移到另一種工具以獲得一些新的特性。比如,Mac OS X操作系統自帶了`Setuptools`,因而裝有`easy_install`工具。當他們想要切換到新的工具時,該工具就必須兼容現有的環境。
如果系統使用類似RPM這樣的工具管理Python軟件包,那么其它安裝工具在安裝新項目時是無法通知到系統的。更糟糕的是,即便Python安裝工具能夠通知到中央打包系統,我們也必須在Python元信息和系統元信息之間做一個映射。比如,項目的名稱在兩個系統中可能是不一致的。造成這種問題的原因也多種多樣,比較常見的原因是命名沖突,即RPM源中已經有一個同名的項目了。另一個原因是項目名稱中包含了`python`這個前綴,從而破壞了RPM系統的規范。比如,你的項目名稱是`foo-python`,那在RPM源中很可能被表示為`python-foo`。
一種解決辦法是不去觸碰全局的Python環境,而是使用一個隔離的環境,如`Virtualenv`。
但不管怎樣,采用統一的Python安裝格式還是有必要的,因為其它一些打包系統在為自己安裝Python項目時還是需要考慮互通性。當第三方打包系統新安裝了一個項目,并在自身的數據庫中注冊后,它還需要為Python安裝環境生成一個正確的元信息,從而讓項目在這個環境中變得可見,或能通過該Python環境提供的API檢索到。
元信息的映射問題可以這樣描述:因為RPM系統知道自己安裝了哪些Python項目,它就能生成合適的Python元信息。例如,它知道`python26-webob`項目在PyPI中的名字是`WebOb`。
回到我們的規范:PEP 376定義的項目安裝規范和`Seteptools`以及`Pip`的格式很相似,它是一個以`dist-info`結尾的目錄,包含以下內容:
* `METADATA`:元信息,其格式在PEP 345、PEP 314和PEP 241中描述。
* `RECORD`:項目安裝的文件列表,以類似csv的格式保存。
* `INSTALLER`:安裝項目所使用的工具。
* `REQUESTED`:如果這個文件存在,則表明這個項目是被顯式安裝的,即并不是作為依賴項而安裝。
如果所有的安裝工具都能識別這種格式,我們在管理Python項目時就不需要依賴特定的安裝工具和它提供的特性了。此外,PEP 376將元信息設計為一個目錄,這樣就能方便地擴展。事實上,下一章要描述的`RESOURCES`文件很可能會在不久的將來添加到元信息中,而不用改變PEP 376標準。當事實證明這個文件能被所有的安裝工具使用,則會將它修訂到PEP中。
### 14.4.3 數據文件的結構
前面已經提到,我們需要能夠讓打包者來決定項目的數據文件安裝在哪個位置,而不用修改代碼。同樣,也要能夠讓開發者在開發時不用去考慮數據文件的存放位置。我們的解決方案很普通:重定向。
#### 使用數據文件
假設你的`MPTools`項目需要使用一個配置文件。開發者會將改文件放到Python包安裝目錄中,并使用`__file__`去引用:
~~~
import os
here = os.path.dirname(__file__)
cfg = open(os.path.join(here, 'config', 'mopy.cfg'))
~~~
這樣編寫代碼意味著該配置文件必須和代碼放在相同的位置,一個名為`config`的子目錄下。
我們設計的新的數據文件結構以項目為根節點,開發者可以定義任意的文件目錄結構,而不用關心根目錄是存放在軟件安裝目錄中或是其它目錄。開發者可以使用`pkgutil.open`來訪問這些數據文件:
~~~
import os
import pkgutil
# Open the file located in config/mopy.cfg in the MPTools project
cfg = pkgutil.open('MPTools', 'config/mopy.cfg')
~~~
`pkgutil.open`命令會檢索項目元信息中的`RESOURCES`文件,該文件保存的是一個簡單的映射信息——文件名稱和它所存放的位置:
~~~
config/mopy.cfg {confdir}/{distribution.name}
~~~
其中,`{confdir}`變量指向系統的配置文件目錄,`{distribution.name}`變量表示的是Python項目名稱。

圖14.4:定位一個文件
只要安裝過程中生成了`RESOURCES`文件,這個API就能幫助開發者找到`mopy.cfg`文件。又因為`config/mopy.cfg`是一個相對于項目的路徑,我們就能在開發模式下提供一個本地的路徑,讓`pkgutil`能夠找到它。
#### 聲明數據文件
實際使用中,我們可以在`setup.cfg`文件中用映射關系來定義數據文件的存放位置。映射關系的形式是`(glob-style pattern, target)`,每個“模式”指向項目中的一個或一組文件,“目標”則表示實際安裝位置,可以包含變量名,用花括號括起。例如,`MPTools`的`setup.cfg`文件可以是以下內容:
~~~
[files]
resources =
config/mopy.cfg {confdir}/{application.name}/
images/*.jpg {datadir}/{application.name}/
~~~
`sysconfig`模塊提供了一組可用的變量,并為不同的操作系統提供了默認值。例如,`{confdir}`在Linux下是`/etc`。安裝工具就能結合`sysconfig`模塊來決定數據文件的存放位置。最后,它會生成一個`RESOURCES`文件,這樣`pkgutil`就能找到這些文件了:

圖14.5:安裝工具
### 14.4.4 改進PypI
上文提到過,PyPI目前是一個單點故障源。PEP 380中正式提出了這個問題,并定義了一個鏡像協議,使得用戶可以在PyPI出現問題時連接到其他源。這個協議的目的是讓社區成員可以在世界各地搭建起PyPI鏡像。

圖14.6:鏡像
鏡像列表的格式是`X.pypi.python.org`,其中`X`是一個字母序列,如`a,b,c,…,aa,ab,….`,`a.pypi.python.org`是主服務器,b字母開始是從服務器。域名`last.pypi.python.org`的A記錄指向這個列表中的最后一個服務器,這樣PyPI的使用者就能夠根據DNS記錄來獲取完整的服務器鏡像列表了。
比如,以下代碼獲取到的最后一個鏡像地址是`h.pypi.python.org`,表示當前PyPI有7個鏡像服務器(b至h):
~~~
>>> import socket
>>> socket.gethostbyname_ex('last.pypi.python.org')[0]
'h.pypi.python.org'
~~~
這樣一來,客戶端還可以根據域名的IP地址來決定連接最近的鏡像服務器,或者在服務器發生故障時自動重連到新的地址。鏡像協議本身要比rsync更復雜一些,因為我們需要保證下載統計量的準確性,并提供最基本的安全性保障。
#### 同步
鏡像必須盡可能降低和主服務器之間的數據交換,要達到這個目的,就必須在PyPI的XML-RPC接口中加入`changelog`信息,以保證只獲取變化的內容。對于每個軟件包“P”,鏡像必須復制`/simple/P/`和`/serversig/P`這兩組信息。
如果中央服務器中刪除了一個軟件包,它就必須刪除所有和它有關的數據。為了檢測軟件包文件的變動,可以緩存文件的ETag信息,并通過`If-None-Match`頭來判斷是否可以跳過傳輸過程。當同步完成后,鏡像就將`/last-modified`文件設置為當前的時間。
#### 統計信息
當用戶在鏡像中下載一個軟件包時,鏡像就需要將這個事件報告給中央服務器,繼而廣播給其他鏡像服務器。這樣就能保證下載工具在任意鏡像都能獲得正確的下載量統計信息。
統計信息以CSV文件的格式保存在中央服務器的`stats`目錄中,按照日和周分隔。每個鏡像服務器需要提供一個`local-stats`目錄來存放它自己的統計信息。文件中保存了每個軟件包的下載數量,以及它們的下載工具。中央服務器每天都會從鏡像服務器中獲取這些信息,將其合并到全局的`stats`目錄,這樣就能保證鏡像服務器中的`local-stats`目錄中的數據至少是每日更新的。
#### 鏡像服務器的合法性
在分布式的鏡像系統中,客戶端需要能夠驗證鏡像服務器的合法性。如果不這樣做,就可能產生以下威脅:
* 中央索引發生錯誤
* 鏡像服務被篡改
* 服務器和客戶端之間遭到攔截攻擊
對于第一種攻擊,軟件包的作者就需要使用自己的PGP密鑰來對軟件包進行加密,這樣其他用戶就能判斷他所下載的軟件包是來自可信任的作者的。鏡像服務協議中只對第二種攻擊做了預防,不過有些措施也可以預防攔截攻擊。
中央服務器會在`/serverkey`這個URL下提供一個DSA密鑰,它是用`opensll dsa-pubout`3生成的PEM格式的密鑰。這個URL不能被鏡像服務器收錄,客戶端必須從主服務器中獲取這個serverkey密鑰,或者使用PyPI客戶端本身自帶的密鑰。鏡像服務器也是需要下載這個密鑰的,用來檢測密鑰是否有更新。
對于每個軟件包,`/serversig/package`中存放了它們的鏡像簽名。這是一個DSA簽名,和URL`/simple/package`包含的內容對等,采用DER格式,是SHA-1和DSA的結合4。
客戶端從鏡像服務器下載軟件包時必須經過以下驗證:
1. 下載`/simple`頁面,計算它的`SHA-1`哈希值。
2. 計算這個哈希值的DSA簽名。
3. 下載對應的`/serversig`,將它和第二步中生成的簽名進行比對。
4. 計算并驗證所有下載文件的MD5值(和`/simple`頁面中的內容對比)。
在從中央服務器下載軟件包時不需要進行上述驗證,客戶端也不應該進行驗證,以減少計算量。
這些密鑰大約每隔一年會被更新一次。鏡像服務器需要重新獲取所有的`/serversig`頁面內容,使用鏡像服務的客戶端也需要通過可靠的方式獲取新密鑰。一種做法是從`https://pypi.python.org/serverkey`下載。為了檢測攔截攻擊,客戶端需要通過CAC認證中心驗證服務端的SSL證書。
## 14.5 實施細節
上文提到的大多數改進方案都在`Distutils2`中實現了。`setup.py`文件已經退出歷史舞臺,取而代之的是`setup.cfg`,一個類似`.ini`類型的文件,它描述了項目的所有信息。這樣做可以讓打包人員方便地改變軟件包的安裝方式,而不需要接觸Python語言。以下是一個配置文件的示例:
~~~
[metadata]
name = MPTools
version = 0.1
author = Tarek Ziade
author-email = tarek@mozilla.com
summary = Set of tools to build Mozilla Services apps
description-file = README
home-page = http://bitbucket.org/tarek/pypi2rpm
project-url: Repository, http://hg.mozilla.org/services/server-devtools
classifier = Development Status :: 3 - Alpha
License :: OSI Approved :: Mozilla Public License 1.1 (MPL 1.1)
[files]
packages =
mopytools
mopytools.tests
extra_files =
setup.py
README
build.py
_build.py
resources =
etc/mopytools.cfg {confdir}/mopytools
~~~
`Distutils2`會將這個文件用作于:
* 生成`META-1.2`格式的元信息,可以用作多種用途,如在PyPI上注冊項目。
* 執行任何打包管理命令,如`sdist`。
* 安裝一個以`Distutils2`為基礎的項目。
`Distutils2`還通過`version`模塊實現了`VERSION`元信息。
對`INSTALL-DB`元信息的實現會被包含在Python3.3的`pkgutil`模塊中。在過度版本中,它的功能會由`Distutils2`完成。它所提供的API可以讓我們瀏覽系統中已安裝的項目。
以下是`Distutils2`提供的核心功能:
* 安裝、卸載
* 依賴樹
## 14.6 經驗教訓
### 14.6.1 PEP的重要性
要改變像Python打包系統這樣龐大和復雜的架構必須通過謹慎地修改PEP標準來進行。據我所知,任何對PEP的修改和添加都要歷經一年左右的時間。
社區中一直以來有個錯誤的做法:為了改善某個問題,就肆意擴展項目元信息,或是修改Python程序的安裝方式,而不去嘗試修訂它所違背的PEP標準。
換句話說,根據你所使用的安裝工具的不同,如`Distutils`和`Setuptools`,它們安裝應用程序的方式就是不同的。這些工具的確解決了一些問題,但卻會引發一連串的新問題。以操作系統的打包工具為例,管理員必須面對多個Python標準:官方文檔所描述的標準,`Setuptools`強加給大家的標準。
但是,`Setuptools`能夠有機會在實際環境中大范圍地(在整個社區中)進行實驗,創新的進度很快,得到的反饋信息也是無價的。我們可以據此撰寫出更切合實際的PEP新標準。所以,很多時候我們需要能夠察覺到某個第三方工具在為Python社區做出貢獻,并應該起草一個新的PEP標準來解決它所提出的問題。
### 14.6.2 一個被納入標準庫的項目就已經死亡了一半
這個標題是援引Guido van Rossum的話,而事實上,Python的這種戰爭式的哲學也的確沖擊了我們的努力成果。
`Distutils`是Python標準庫之一,將來`Distutils2`也會成為標準庫。一個被納入標準庫的項目很難再對其進行改造。雖然我們有正常的項目更新流程,即經過兩個Python次版本就可以對某個API進行刪改,但一旦某個API被發布,它必定會持續存在多年。
因此,對標準庫中某個項目的一次修改并不是簡單的bug修復,而很有可能影響整個生態系統。所以,當你需要進行重大更新時,就必須創建一個新的項目。
我之所以深有體會,就是因為在我對`Distutils`進行了超過一年的修改后,還是不得不回滾所有的代碼,開啟一個新的`Distutils2`項目。將來,如果我們的標準又一次發生了重大改變,很有可能會產生`Distutils3`項目,除非未來某一天標準庫會作為獨立的項目發行。
### 14.6.3 向前兼容
要改變Python項目的打包方式,其過程是非常漫長的:Python的生態系統中包含了那么多的項目,它們都采用舊的打包工具管理,一定會遇到諸多阻力。(文中一些章節描述的問題,我們花費了好幾年才達成共識,而不是我之前預想的幾個月。)對于Python3,可能會花費數年的時間才能將所有的項目都遷移到新的標準中去。
這也是為什么我們做的任何修改都必須兼容舊的打包工具,這是`Distutils2`編寫過程中非常棘手的問題。
例如,一個以新標準進行打包的項目可能會依賴一個尚未采用新標準的其它項目,我們不能因此中斷安裝過程,并告知用戶這是一個無法識別的依賴項。
舉例來說,`INSTALL-DB`元信息的實現中會包含那些用`Distutils`、`Pip`、`Distribution`、或`Setuptools`安裝的項目。`Distutils2`也會為那些使用`Distutils`安裝的項目生成新的元信息。
## 14.7 參考和貢獻者
本文的部分章節直接摘自PEP文檔,你可以在`http://python.org`中找到原文:
* PEP 241: Metadata for Python Software Packages 1.0: http://python.org/peps/pep-0214.html
* PEP 314: Metadata for Python Software Packages 1.1: http://python.org/peps/pep-0314.html
* PEP 345: Metadata for Python Software Packages 1.2: http://python.org/peps/pep-0345.html
* PEP 376: Database of Installed Python Distributions: http://python.org/peps/pep-0376.html
* PEP 381: Mirroring infrastructure for PyPI: http://python.org/peps/pep-0381.html
* PEP 386: Changing the version comparison module in Distutils: http://python.org/peps/pep-0386.html
在這里我想感謝所有為打包標準的制定做出貢獻的人們,你可以在PEP中找到他們的名字。我還要特別感謝“打包別動隊”的成員們。還要謝謝Alexis Metaireau、Toshio Kuratomi、Holger Krekel、以及Stefane Fermigier,感謝他們對本文提供的反饋。
本章中討論的項目有:
* Distutils: http://docs.python.org/distutils
* Distutils2: http://packages.python.org/Distutils2
* Distribute: http://packages.python.org/distribute
* Setuptools: http://pypi.python.org/pypi/setuptools
* Pip: http://pypi.python.org/pypi/pip
* Virtualenv: http://pypi.python.org/pypi/virtualenv
## 腳注
1. 文中引用的Python改進提案(Python Enhancement Proposals,簡稱PEP)會在本文最后一節整理。
2. 過去被命名為CheeseShop
3. 即RFC 3280 SubjectPublishKeyInfo中定義的1.3.14.3.2.12算法。
4. 即RFC 3279 Dsa-Sig-Value中定義的1.2.840.10040.4.3算法。
- 前言(卷一)
- 卷1:第1章 Asterisk
- 卷1:第3章 The Bourne-Again Shell
- 卷1:第5章 CMake
- 卷1:第6章 Eclipse之一
- 卷1:第6章 Eclipse之二
- 卷1:第6章 Eclipse之三
- 卷1:第8章 HDFS——Hadoop分布式文件系統之一
- 卷1:第8章 HDFS——Hadoop分布式文件系統之二
- 卷1:第8章 HDFS——Hadoop分布式文件系統
- 卷1:第12章 Mercurial
- 卷1:第13章 NoSQL生態系統
- 卷1:第14章 Python打包工具
- 卷1:第15章 Riak與Erlang/OTP
- 卷1:第16章 Selenium WebDriver
- 卷1:第18章 SnowFlock
- 卷1:第22章 Violet
- 卷1:第24章 VTK
- 卷1:第25章 韋諾之戰
- 卷2:第1章 可擴展Web架構與分布式系統之一
- 卷2:第1章 可擴展Web架構與分布式系統之二
- 卷2:第2章 Firefox發布工程
- 卷2:第3章 FreeRTOS
- 卷2:第4章 GDB
- 卷2:第5章 Glasgow Haskell編譯器
- 卷2:第6章 Git
- 卷2:第7章 GPSD
- 卷2:第9章 ITK
- 卷2:第11章 matplotlib
- 卷2:第12章 MediaWiki之一
- 卷2:第12章 MediaWiki之二
- 卷2:第13章 Moodle
- 卷2:第14章 NginX
- 卷2:第15章 Open MPI
- 卷2:第18章 Puppet part 1
- 卷2:第18章 Puppet part 2
- 卷2:第19章 PyPy
- 卷2:第20章 SQLAlchemy
- 卷2:第21章 Twisted
- 卷2:第22章 Yesod
- 卷2:第24章 ZeroMQ