如何建立Python程式包,Python程式包結構詳解(超級詳細)

2020-07-16 10:05:05
我們知道,組織大型應用的程式碼的最簡單方法,通常就是將其分成幾個包,這使得程式碼更加簡單,也更容易理解、維護和修改,同時還可以使每個包的可複用性最大化,它們的作用就像元件一樣。

setup.py指令碼檔案

對於一個需要被分發的包來說,其根目錄包含一個 setup.py 指令碼,它定義了 distutils 模組中描述的所有後設資料,並將其合併為標準的 setup() 函數呼叫的引數。

雖然 distutils 是一個標準庫模組,但建議讀者使用 setuptools 包來代替,因為它對標準的 distutils 做了一些改進。

因此,這個檔案的最少內容如下:
from setuptools import setup
setup(
    name='mypackage',
)
其中,name 給出了包的全名。

另外,該指令碼提供了一些命令,可以用 --help -commands 選項列出這些命令:

$Python setup.py --help-commands
tandard commands:
  build             build everything needed to install
  clean             clean up temporary files from 'build' command
  install           install everything from build directory
  sdist             create a source distribution (tarball, zip file)
  register          register the distribution with the PyP
  bdist             create a built (binary) distribution
  check             perform some checks on the package
  upload            upload binary package to PyPI

Extra commands:
  develop           install package in 'development mode'
  alias             define a shortcut to invoke one or more commands
  test              run unit t#sts after in-place build
  bdist_wheel       create a wheel distribution

usage: setup.py [global_opts] cmd1 [cmd1_opts] [cmd2 [cmd2_opts】...]
  or: setup.py --help [cmd1 end2 ...]
  or: setup.py --help-commands
  or: setup.py cmd --help

實際的命令列表更長,而且會根據 setuptools 的可用擴充套件而變化。這裡所列舉的都是相對來說比較重要的,且和本節相關的命令。

Standard commands(標準命令)是 distutils 提供的內罝命令,而 Extra commands(額外命令)則是由諸如 setuptools 這樣的第三方包或任何其他定義並註冊一個新命令的包所建立的。由另一個包註冊的一個額外命令就是 wheel 包提供的 bdist_wheel。

setup.cfg檔案

setup.cfg 檔案包含 setup.py 指令碼命令的預設選項。如果構建和分發包的過程更加複雜,並且需要向 setup.py 命令中傳入許多可選引數,那麼這個檔案非常有用。

讀者可以按專案將這些預設引數儲存在程式碼中,這將使整個分發流程獨立於專案之外,也能夠讓包的構建方式與向使用者和其他團隊成員的分發方式變得透明。

setup.cfg 檔案的語法與內建 configparser 模組提供的語法相同,因此它類似於常見的 Microsoft Windows INI 檔案。下面是安裝組態檔的範例,提供了 global、sdist 和 bdist_wheel 命令的預設值,程式碼如下:

[global]
quiet=1

[sdist]
formats=zip,tar

[bdist_wheel】
universal=1

這個設定範例可以確保原始碼發行版總是以兩種格式建立(ZIP 和 TAR),並且構建 wheel 發行版將被建立為通用 wheel(與 Python 版本無關)。此外,由於全域性 quiet 開關,每個命令的大部分輸出都將被阻止。

注意,這只是為了便於說明,預設阻止每個命令的輸出可能並不是一個合理的選擇。

MANIFEST.in

使用 sdist 命令構建發行版時,distutils 將瀏覽包的目錄,查詢需要包含在存檔中的檔案。distutils 將包含:
  • py_modules、packages 和 scripts 選項隱含的所有 Python 原始檔;
  • ext_modules 選項列出的所有 C 原始檔。
匹配 glob 模式 test/test*.py 的檔案包括:README、README.txt、setup.py 和 setup.cfg。

此外,如果你的包是由 subversion 或 CVS 管理,那麼 sdist 將瀏覽諸如 .svn 之類的資料夾,查詢需要包含的檔案。利用擴充套件也可以與其他版本控制系統整合。sdist 將構建—個 MANIFEST 檔案,列出所有檔案並將它們包含在存檔中。

假設你不使用這些版本控制系統,並且需要包含更多的檔案,那麼在與 setup.py 相同的目錄中,可以為 MANIFEST 檔案定義一個名為 MANIFEST.in 的模板,在其中可以指定 sdist 要包含哪些檔案。

這個模板的每一行都定義一條包含或排除規則,例如:

include HISTORY.txt
include README.txt
include CHANGES.txt
include CONTRIBUTORS.txt
include LICENSE
recursive-include *.txt *.py

MANIFEST.in 命令的完整列表可以在 distutils 官方文件中找到。

最重要的後設資料

除了被分發包的名稱和版本之外,setup 可以接受的最重要的引數包括:
  • descriptions:包含描述包的幾句話。
  • long_description:包含完整說明,可以使用 reStructuredText 格式。
  • keywords:定義包的關鍵字列表。
  • authors:作者的姓名或組織。
  • author_email:聯絡人電子郵件地址。
  • url:專案的 URL。
  • license:許可證(GPL、LGPL等)
  • packages:包中所有名稱的列表,setuptools 提供了一個名為 find_packages 的小函數來計算它。
  • namespace_packages:命令空間包的列表。

trove分類器

PyPI 和 distutils 為應用程式分類提供了一種解決方案,就是使用一套 trove 分類器。所有 trove 分類器都形成一個樹狀結構,每個分類器都是字元 串形式,其中用 :: 字串分隔每個名稱空間。分類器列表在包定義中是作為 setup() 函數的 classifiers 引數。

下面是 PyPI上某個專案的分類器列表範例(這裡是 solrq 專案):
from setuptools import setup
setup(
    name="solrq",
    # (...)
    classifiers=[
        'Development Status :: 4 - Beta',
        'Intended Audience :: Developers',
        'License :: OSI Approved :: BSD License',
        'Operating System :: OS Independent',
        'Programming Language :: Python',
        'Programming Language :: Python :: 2',
        'Programming Language :: Python :: 2.6',
        'Programming Language :: Python :: 2.7',
        'Programming Language :: Python :: 3',
        'Programming Language :: Python :: 3.2',
        'Programming Language :: Python :: 3.3',
        'Programming Language :: Python :: 3.4',
        'Programming Language :: Python :: Implementation :: PyPy',
        'Topic :: Internet :: WWW/HTTP :: Indexing/Search',
    ],
)
它們在包定義中是完全可選的,但可以對 setup() 介面中可用的基本後設資料提供有用的擴充套件。

此外,trove 分類器還可以提供支援的 Python 版本或系統、專案的開發階段或發布程式碼所使用的許可證等資訊。許多 PyPI 使用者按類別對可用的包進行搜尋和瀏覽,因此正確的分類可以讓 Python 包找到目標客戶。

trove分類器在整個打包生態系統中發揮重要作用,不應該被忽略。沒有一個組織來驗證包的分類,所以我們有責任為自己的包提供正確的分類器,並且不要為整個包索引帶來混亂。

在編寫本教學時,PyPI 上共有 608 個可用的分類器,分為以下 9 類:
  1. 開發狀態(DevelopmentStatus)
  2. 環境(Environment)
  3. 框架(Framework)
  4. 目標受眾(IntendedAudience)
  5. 許可證(License)
  6. 自然語言(Natural Language)
  7. 作業系統(Operating System)
  8. 程式語言(Programming Language)
  9. 話題(Topic)

由於不時會新增新的分類器,所以在閱讀本教學時,這些數位可能會有所不同。當前可用的 trove 分類器的完整列表可以用 setup.py register --list-classifiers 命令來檢視。

常見模式

對於沒有經驗的開發者來說,建立一個用於分發的包可能是一項乏味的任務。如果不考虎後設資料可能在專案其他部分找到的事實,setuptools 或 distuitls 在 setup() 函數呼叫中接受的大多數後設資料都可以手動輸入,程式碼如下:
from setuptools import setup

setup(
    name="myproject",
    version="0.0.1",
    description="mypackage project short description",
    long_description="""
        Longer description of mypackage project possibly with some documentation and/or usage examples
    """
    install_requires=[
        'dependency1',
        'dependency2',
        'etc1',
    ]
)
這麼做當然可行,但從長遠來看很難維護,並且未來可能會出現錯誤和不一致。setuptools 和 distuitls 都不能從專案原始碼中自動提取各種後設資料資訊,因此需要自己提供這些佶息。

在 Python 社群中,有一些常見模式可以解決最常見的問題。例如依賴管理、包含版本/讀我檔案等。至少應該知道其中一些模式,因為它們非常流行,己經被看作一種打包慣例。

1) 自動包含包中的版本字串

PEP440(版本標識和依賴規範)文件規定了版本和依賴規範的標準。這是一份很長的文件,包含已接受的版本規範方案和 Python 打包工具中應該如何做版本匹配和比較。

如果你正在使用或打算使用一種複雜的專案版本編號方案,那麼一定要閱讀這份文件;如果你使用的是一種簡單方案,其中包含用點分開的一個、兩個、三個或更多的數位,那麼可以不必閱讀 PEP 440。

另一個問題是將包或模組的版本識別符號包含在什麼位罝。PEP 396(模組版本號)正好解決這個問題。注意,這份文件只是資訊性的,並且狀態為延期,所以它並不是標準路徑的一部分。不管怎樣,它描述的內容現在似乎成了事實上的標準。

根據 PEP 396,如果一個包或模組要指定一個版本,那麼應該將其包含在包的根目錄(__init__.py)或模組檔案的 __version__ 屬性中。另一個事實上的標準是,也要將包括版本元組的 VERSION 屬性包含其中,這有助於使用者編寫相容程式碼,因為如果版本方案足夠簡單的話,這樣的版本元組很容易比較。

因此,PyPI 上的很多包都進循這兩個標準。它們的 __init__.py 檔案包含如下所示的版本屬性,如下所示:
#用元組表示版本,可以簡單比較
VERSION = (0, 1, 1)
#利用元組建立字串,以避免出現不一致
__version__ = ".".join([str(x) for x in VERSION])
延期的 PEP 396 的另一個建議是,在 distutils 的 setup() 函數中提供的版本應該從 __version__ 派生,反之亦然。Python 打包使用者指南為單一來源的專案版本提供了多種模式,每一種都有自己的優點和侷限性。

我個人最喜歡相當長的,並沒有包含在 PyPA 的指南中,但它的優點是僅限制 setup.py 指令碼的複雜度。這個樣板假定,版本識別符號由包的 __init__ 模組的 VERSION 屬性給出,並且提取這一資料包含在 setup () 呼叫中。

下面是某個虛構的包的 setup.py 指令碼中的片段,其中使用了以下這種方法:
from setuptools import setup
import os
def get_version(version_tuple):
    #additional handling of a,b,rc tags, this can
    #be simpler depending on your versioning scheme
    if not isinstance(version_tuple[-1], int):
        return '.'.join(
            map(str, version.tuple[:-1])
        )+ version.tuple[-1]
    return '.'.join(map(str, version_tuple))
#path to the packages __init__ module in project
#source tree
init = os.path.join(
    os.path.dirname(__file__), 'src', 'some_package','__init__.py'
)

version_line = list(
    filter(lambda l: l.startswith('VERSION'), open(init))
)[0]

# VERSION is a tuple so we need to eval its line of code.
# We could simply import it from the package but we
# cannot be sure that this package is importable before
# finishing its installation

VERSION = get_version(eval (version.line.split('=') [-1]))

setup(
    name='some-package',
    version=VERSION,
    #...
)

2) README檔案

Python 包索引可以在 PyPI 門戶的包頁面中顯示一個專案的 readme 或者 long_description 的值。你可以用 reStructuredText 標記來編寫這個說明,它在上傳時會轉換為 HTML 格式。

不幸的是,目前 PyPI 上的文件標記只能使用 reStructuredText,這在短期內也不太可能改變。更有可能的是,如果 warehouse 專案完全取代了當前的 PyPI 實現,那麼將會支援其他標示語言。但是,我們仍然不知道 warehouse 的最終發布時間。

但是,許多開發者想要使用不同的標示語言,原因有很多。最常見的選擇是 Markdown。它是 GitHub 上預設的標示語言,目前大多數開源的 Python 開發都是在 GitHub 上。

因此,GitHub 和 Markdown 的粉絲通常要麼忽略這個問題,要麼就提供兩份獨立的文件文字。提供給 PyPI 的說明要麼是專案 GitHub 頁面上說明的簡短版本,要麼是在 PyPI 上無法正常顯示的普通的無格式 Markdown。

如果你想使用除了 rcStructurcdText 之外的標示語言來編寫專案的 README,你仍然可以用可讀的形式將它作為 PyPI 頁面上的專案說明。訣竅是在將包上傳到 Python 包索引時使用 pypandoc 包將你使用的其他指令碼語言轉換成 reStructuredText,同時準備 readme 檔案的簡單內容作為備用(fallback)也很重要,這樣即使使用者沒有安裝 pypandoc,安裝也不會失敗,程式碼如下:
try:
    from pypandoc import convert
    def read_md(f):
        return convert(f, 'rst')
except ImportError:
    convert = None
    print(
        "warning: pypandoc module not found, could not convert Markdown to RST"
    )

    def readjnd(f):
        return open(f, 'r').read() #noqa
README = os.path.join (os.path.dirname(__file__), 'README.md')

setup(
    name='some-package',
    long_description=read_md(README),
    #...
)

3) 管理依賴

許多專案需要安裝和(或)使用一些外部包。如果依賴列表很長的話,就會出現一個問題,即如何管理依賴?在大多數情況下答案很簡單,不要過度設計問題。保持簡單,並在 setup.py 指令碼中明確提供依賴列表,程式碼如下:
from setuptools import setup
setup(
    name = 'some-package',
    install_requires=['falcon', 'requests', 'delorean']
    #...
)
有些 Python 開發者喜歡使用 requirements.txt 檔案來追蹤包的依賴列表。在某些情況下,你可能會找到這麼做的原因,但在大多數情況下,這是專案程式碼沒有正確打包的時代遺留的問題。

無論如何,即使像 Celery 這樣著名的專案也仍然堅持使用這一約定。因此,如果你不願意改變習慣或者不知何故被迫使用 requirements.txt 檔案,那麼至少要將其做對。下面是從 requirements.txt 檔案讀取依賴列表的常見做法之一:
from setuptools import setup
import os
def strip_comments(l):
    return l.split ('#', 1)[0].strip()

def reqs(*f):
    return list(filter(None, [strip_comments(l) for l in open(os.path.join(os.getcwd(), *f)).readlines()]))

setup(
    name='some-package',
    install.requires = reqs('requirements.txt')
    #...
)