鍍金池/ 教程/ Python/ 模塊與包
類與對象
模塊與包
數(shù)據(jù)編碼和處理
元編程
網(wǎng)絡與 Web 編程
數(shù)字日期和時間
測試、調(diào)試和異常
字符串和文本
文件與 IO
腳本編程與系統(tǒng)管理
迭代器與生成器
函數(shù)
C 語言擴展
并發(fā)編程
數(shù)據(jù)結構和算法

模塊與包

模塊與包是任何大型程序的核心,就連 Python 安裝程序本身也是一個包。本章重點涉及有關模塊和包的常用編程技術,例如如何組織包、把大型模塊分割成多個文件、創(chuàng)建命名空間包。同時,也給出了讓你自定義導入語句的秘籍。

構建一個模塊的層級包

問題

你想將你的代碼組織成由很多分層模塊構成的包。

解決方案

封裝成包是很簡單的。在文件系統(tǒng)上組織你的代碼,并確保每個目錄都定義了一個init.py 文件。 例如:

graphics/
    __init__.py
    primitive/
        __init__.py
        line.py
        fill.py
        text.py
    formats/
        __init__.py
        png.py
        jpg.py

一旦你做到了這一點,你應該能夠執(zhí)行各種 import 語句,如下:

import graphics.primitive.line
from graphics.primitive import line
import graphics.formats.jpg as jpg

討論

定義模塊的層次結構就像在文件系統(tǒng)上建立目錄結構一樣容易。 文件init.py 的目的是要包含不同運行級別的包的可選的初始化代碼。 舉個例子,如果你執(zhí)行了語句 import graphics, 文件 graphics/init.py 將被導入,建立 graphics 命名空間的內(nèi)容。像 import graphics.format.jpg 這樣導入,文件 graphics/init.py 和文件 graphics/graphics/formats/init.py 將在文件 graphics/formats/jpg.py 導入之前導入。

絕大部分時候讓init.py 空著就好。但是有些情況下可能包含代碼。 舉個例子,init.py 能夠用來自動加載子模塊:

# graphics/formats/__init__.py
from . import jpg
from . import png

像這樣一個文件,用戶可以僅僅通過 import grahpics.formats 來代替 import graphics.formats.jpg 以及 import graphics.formats.png。

init.py 的其他常用用法包括將多個文件合并到一個邏輯命名空間,這將在10.4小節(jié)討論。

敏銳的程序員會發(fā)現(xiàn),即使沒有init.py 文件存在,python 仍然會導入包。如果你沒有定義init.py 時,實際上創(chuàng)建了一個所謂的“命名空間包”,這將在10.5小節(jié)討論。萬物平等,如果你著手創(chuàng)建一個新的包的話,包含一個init.py 文件吧。

控制模塊被全部導入的內(nèi)容

問題

當使用’from module import *‘ 語句時,希望對從模塊或包導出的符號進行精確控制。

解決方案

在你的模塊中定義一個變量 all 來明確地列出需要導出的內(nèi)容。

舉個例子:

# somemodule.py
def spam():
    pass

def grok():
    pass

blah = 42
# Only export 'spam' and 'grok'
__all__ = ['spam', 'grok']

討論

盡管強烈反對使用 ‘from module import *‘, 但是在定義了大量變量名的模塊中頻繁使用。 如果你不做任何事, 這樣的導入將會導入所有不以下劃線開頭的。 另一方面,如果定義了 all , 那么只有被列舉出的東西會被導出。

如果你將 all 定義成一個空列表, 沒有東西將被導出。 如果 all 包含未定義的名字, 在導入時引起 AttributeError。

使用相對路徑名導入包中子模塊

問題

將代碼組織成包,想用 import 語句從另一個包名沒有硬編碼過的包的中導入子模塊。

解決方案

使用包的相對導入,使一個的模塊導入同一個包的另一個模塊 舉個例子,假設在你的文件系統(tǒng)上有 mypackage 包,組織如下:

mypackage/
    __init__.py
    A/
        __init__.py
        spam.py
        grok.py
    B/
        __init__.py
        bar.py

如果模塊 mypackage.A.spam 要導入同目錄下的模塊 grok,它應該包括的 import 語句如下:

# mypackage/A/spam.py
from . import grok

如果模塊 mypackage.A.spam 要導入不同目錄下的模塊 B.bar,它應該使用的 import 語句如下:

# mypackage/A/spam.py
from ..B import bar

兩個 import 語句都沒包含頂層包名,而是使用了 spam.py 的相對路徑。

討論

在包內(nèi),既可以使用相對路徑也可以使用絕對路徑來導入。 舉個例子:

# mypackage/A/spam.py
from mypackage.A import grok # OK
from . import grok # OK
import grok # Error (not found)

像 mypackage.A 這樣使用絕對路徑名的不利之處是這將頂層包名硬編碼到你的源碼中。如果你想重新組織它,你的代碼將更脆,很難工作。 舉個例子,如果你改變了包名,你就必須檢查所有文件來修正源碼。 同樣,硬編碼的名稱會使移動代碼變得困難。舉個例子,也許有人想安裝兩個不同版本的軟件包,只通過名稱區(qū)分它們。 如果使用相對導入,那一切都 ok,然而使用絕對路徑名很可能會出問題。

import 語句的 . 和 ..看起來很滑稽, 但它指定目錄名.為當前目錄,..B 為目錄../B。這種語法只適用于 import。 舉個例子:

from . import grok # OK
import .grok # ERROR

盡管使用相對導入看起來像是瀏覽文件系統(tǒng),但是不能到定義包的目錄之外。也就是說,使用點的這種模式從不是包的目錄中導入將會引發(fā)錯誤。

最后,相對導入只適用于在合適的包中的模塊。尤其是在頂層的腳本的簡單模塊中,它們將不起作用。如果包的部分被作為腳本直接執(zhí)行,那它們將不起作用 例如:

% python3 mypackage/A/spam.py # Relative imports fail

另一方面,如果你使用 Python 的-m 選項來執(zhí)行先前的腳本,相對導入將會正確運行。 例如:

% python3 -m mypackage.A.spam # Relative imports work

更多的包的相對導入的背景知識,請看 PEP 328 .

將模塊分割成多個文件

問題

你想將一個模塊分割成多個文件。但是你不想將分離的文件統(tǒng)一成一個邏輯模塊時使已有的代碼遭到破壞。

解決方案

程序模塊可以通過變成包來分割成多個獨立的文件??紤]下下面簡單的模塊:

# mymodule.py
class A:
    def spam(self):
        print('A.spam')

class B(A):
    def bar(self):
        print('B.bar')

假設你想 mymodule.py 分為兩個文件,每個定義的一個類。要做到這一點,首先用 mymodule 目錄來替換文件 mymodule.py。 這這個目錄下,創(chuàng)建以下文件:

mymodule/
    __init__.py
    a.py
    b.py

在 a.py 文件中插入以下代碼:

# a.py
class A:
    def spam(self):
        print('A.spam')

在 b.py 文件中插入以下代碼:

# b.py
from .a import A
class B(A):
    def bar(self):
        print('B.bar')

最后,在 init.py 中,將2個文件粘合在一起:

# __init__.py
from .a import A
from .b import B

如果按照這些步驟,所產(chǎn)生的包 MyModule 將作為一個單一的邏輯模塊:

>>> import mymodule
>>> a = mymodule.A()
>>> a.spam()
A.spam
>>> b = mymodule.B()
>>> b.bar()
B.bar
>>>

討論

在這個章節(jié)中的主要問題是一個設計問題,不管你是否希望用戶使用很多小模塊或只是一個模塊。舉個例子,在一個大型的代碼庫中,你可以將這一切都分割成獨立的文件,讓用戶使用大量的 import 語句,就像這樣:

from mymodule.a import A
from mymodule.b import B
...

這樣能工作,但這讓用戶承受更多的負擔,用戶要知道不同的部分位于何處。通常情況下,將這些統(tǒng)一起來,使用一條 import 將更加容易,就像這樣:

from mymodule import A, B

對后者而言,讓 mymodule 成為一個大的源文件是最常見的。但是,這一章節(jié)展示了如何合并多個文件合并成一個單一的邏輯命名空間。 這樣做的關鍵是創(chuàng)建一個包目錄,使用 init.py 文件來將每部分粘合在一起。

當一個模塊被分割,你需要特別注意交叉引用的文件名。舉個例子,在這一章節(jié)中,B類需要訪問A類作為基類。用包的相對導入 from .a import A 來獲取。

整個章節(jié)都使用包的相對導入來避免將頂層模塊名硬編碼到源代碼中。這使得重命名模塊或者將它移動到別的位置更容易。(見10.3小節(jié))

作為這一章節(jié)的延伸,將介紹延遲導入。如圖所示,init.py 文件一次導入所有必需的組件的。但是對于一個很大的模塊,可能你只想組件在需要時被加載。 要做到這一點,init.py 有細微的變化:

# __init__.py
def A():
    from .a import A
    return A()

def B():
    from .b import B
    return B()

在這個版本中,類 A 和類 B 被替換為在第一次訪問時加載所需的類的函數(shù)。對于用戶,這看起來不會有太大的不同。 例如:

>>> import mymodule
>>> a = mymodule.A()
>>> a.spam()
A.spam
>>>

延遲加載的主要缺點是繼承和類型檢查可能會中斷。你可能會稍微改變你的代碼,例如:

if isinstance(x, mymodule.A): # Error
...

if isinstance(x, mymodule.a.A): # Ok
...

延遲加載的真實例子, 見標準庫 multiprocessing/init.py 的源碼.

利用命名空間導入目錄分散的代碼

問題

你可能有大量的代碼,由不同的人來分散地維護。每個部分被組織為文件目錄,如一個包。然而,你希望能用共同的包前綴將所有組件連接起來,不是將每一個部分作為獨立的包來安裝。

解決方案

從本質(zhì)上講,你要定義一個頂級 Python 包,作為一個大集合分開維護子包的命名空間。這個問題經(jīng)常出現(xiàn)在大的應用框架中,框架開發(fā)者希望鼓勵用戶發(fā)布插件或附加包。

在統(tǒng)一不同的目錄里統(tǒng)一相同的命名空間,但是要刪去用來將組件聯(lián)合起來的init.py 文件。假設你有 Python 代碼的兩個不同的目錄如下:

foo-package/
    spam/
        blah.py

bar-package/
    spam/
        grok.py

在這2個目錄里,都有著共同的命名空間 spam。在任何一個目錄里都沒有init.py 文件。

讓我們看看,如果將 foo-package 和 bar-package 都加到 python 模塊路徑并嘗試導入會發(fā)生什么

>>> import sys
>>> sys.path.extend(['foo-package', 'bar-package'])
>>> import spam.blah
>>> import spam.grok
>>>

兩個不同的包目錄被合并到一起,你可以導入 spam.blah 和 spam.grok,并且它們能夠工作。

討論

在這里工作的機制被稱為“包命名空間”的一個特征。從本質(zhì)上講,包命名空間是一種特殊的封裝設計,為合并不同的目錄的代碼到一個共同的命名空間。對于大的框架,這可能是有用的,因為它允許一個框架的部分被單獨地安裝下載。它也使人們能夠輕松地為這樣的框架編寫第三方附加組件和其他擴展。

包命名空間的關鍵是確保頂級目錄中沒有init.py 文件來作為共同的命名空間。缺失init.py 文件使得在導入包的時候會發(fā)生有趣的事情:這并沒有產(chǎn)生錯誤,解釋器創(chuàng)建了一個由所有包含匹配包名的目錄組成的列表。特殊的包命名空間模塊被創(chuàng)建,只讀的目錄列表副本被存儲在其path變量中。 舉個例子:

>>> import spam
>>> spam.__path__
_NamespacePath(['foo-package/spam', 'bar-package/spam'])
>>>

在定位包的子組件時,目錄path將被用到(例如, 當導入 spam.grok 或者 spam.blah 的時候).

包命名空間的一個重要特點是任何人都可以用自己的代碼來擴展命名空間。舉個例子,假設你自己的代碼目錄像這樣:

my-package/
    spam/
        custom.py

如果你將你的代碼目錄和其他包一起添加到 sys.path,這將無縫地合并到別的 spam 包目錄中:

>>> import spam.custom
>>> import spam.grok
>>> import spam.blah
>>>

一個包是否被作為一個包命名空間的主要方法是檢查其file屬性。如果沒有,那包是個命名空間。這也可以由其字符表現(xiàn)形式中的“namespace”這個詞體現(xiàn)出來。

>>> spam.__file__
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
AttributeError: 'module' object has no attribute '__file__'
>>> spam
<module 'spam' (namespace)>
>>>

更多的包命名空間信息可以查看 PEP 420.

重新加載模塊

問題

你想重新加載已經(jīng)加載的模塊,因為你對其源碼進行了修改。

解決方案

使用 imp.reload()來重新加載先前加載的模塊。舉個例子:

>>> import spam
>>> import imp
>>> imp.reload(spam)
<module 'spam' from './spam.py'>
>>>

討論

重新加載模塊在開發(fā)和調(diào)試過程中常常很有用。但在生產(chǎn)環(huán)境中的代碼使用會不安全,因為它并不總是像您期望的那樣工作。

reload()擦除了模塊底層字典的內(nèi)容,并通過重新執(zhí)行模塊的源代碼來刷新它。模塊對象本身的身份保持不變。因此,該操作在程序中所有已經(jīng)被導入了的地方更新了模塊。

盡管如此,reload()沒有更新像”from module import name”這樣使用 import 語句導入的定義。舉個例子:

# spam.py
def bar():
    print('bar')

def grok():
    print('grok')

現(xiàn)在啟動交互式會話:

>>> import spam
>>> from spam import grok
>>> spam.bar()
bar
>>> grok()
grok
>>>

不退出 Python 修改 spam.py 的源碼,將 grok()函數(shù)改成這樣:

def grok():
    print('New grok')

現(xiàn)在回到交互式會話,重新加載模塊,嘗試下這個實驗:

>>> import imp
>>> imp.reload(spam)
<module 'spam' from './spam.py'>
>>> spam.bar()
bar
>>> grok() # Notice old output
grok
>>> spam.grok() # Notice new output
New grok
>>>

在這個例子中,你看到有2個版本的 grok()函數(shù)被加載。通常來說,這不是你想要的,而是令人頭疼的事。

因此,在生產(chǎn)環(huán)境中可能需要避免重新加載模塊。在交互環(huán)境下調(diào)試,解釋程序并試圖弄懂它。

運行目錄或壓縮文件

問題

您有已經(jīng)一個復雜的腳本到涉及多個文件的應用程序。你想有一些簡單的方法讓用戶運行程序。

解決方案

如果你的應用程序已經(jīng)有多個文件,你可以把你的應用程序放進它自己的目錄并添加一個main.py 文件。 舉個例子,你可以像這樣創(chuàng)建目錄:

myapplication/
    spam.py
    bar.py
    grok.py
    __main__.py

如果main.py 存在,你可以簡單地在頂級目錄運行 Python 解釋器:

bash % python3 myapplication

解釋器將執(zhí)行main.py 文件作為主程序。

如果你將你的代碼打包成 zip 文件,這種技術同樣也適用,舉個例子:

bash % ls
spam.py bar.py grok.py __main__.py
bash % zip -r myapp.zip *.py
bash % python3 myapp.zip
... output from __main__.py ...

討論

創(chuàng)建一個目錄或 zip 文件并添加main.py 文件來將一個更大的 Python 應用打包是可行的。這和作為標準庫被安裝到 Python 庫的代碼包是有一點區(qū)別的。相反,這只是讓別人執(zhí)行的代碼包。

由于目錄和 zip 文件與正常文件有一點不同,你可能還需要增加一個 shell 腳本,使執(zhí)行更加容易。例如,如果代碼文件名為 myapp.zip,你可以創(chuàng)建這樣一個頂級腳本:

#!/usr/bin/env python3 /usr/local/bin/myapp.zip

讀取位于包中的數(shù)據(jù)文件

問題

你的包中包含代碼需要去讀取的數(shù)據(jù)文件。你需要盡可能地用最便捷的方式來做這件事。

解決方案

假設你的包中的文件組織成如下:

mypackage/
    __init__.py
    somedata.dat
    spam.py

現(xiàn)在假設 spam.py 文件需要讀取 somedata.dat 文件中的內(nèi)容。你可以用以下代碼來完成:

# spam.py
import pkgutil
data = pkgutil.get_data(__package__, 'somedata.dat')

由此產(chǎn)生的變量是包含該文件的原始內(nèi)容的字節(jié)字符串。

討論

要讀取數(shù)據(jù)文件,你可能會傾向于編寫使用內(nèi)置的 I/O 功能的代碼,如 open()。但是這種方法也有一些問題。

首先,一個包對解釋器的當前工作目錄幾乎沒有控制權。因此,編程時任何 I/O 操作都必須使用絕對文件名。由于每個模塊包含有完整路徑的file變量,這弄清楚它的路徑不是不可能,但它很凌亂。

第二,包通常安裝作為.zip 或.egg 文件,這些文件像文件系統(tǒng)上的一個普通目錄一樣不會被保留。因此,你試圖用 open()對一個包含數(shù)據(jù)文件的歸檔文件進行操作,它根本不會工作。

pkgutil.get_data()函數(shù)是一個讀取數(shù)據(jù)文件的高級工具,不用管包是如何安裝以及安裝在哪。它只是工作并將文件內(nèi)容以字節(jié)字符串返回給你

get_data()的第一個參數(shù)是包含包名的字符串。你可以直接使用包名,也可以使用特殊的變量,比如package。第二個參數(shù)是包內(nèi)文件的相對名稱。如果有必要,可以使用標準的 Unix 命名規(guī)范到不同的目錄,只有最后的目錄仍然位于包中。

將文件夾加入到 sys.path

問題

你無法導入你的 Python 代碼因為它所在的目錄不在 sys.path 里。你想將添加新目錄到 Python 路徑,但是不想硬鏈接到你的代碼。

解決方案

有兩種常用的方式將新目錄添加到 sys.path。第一種,你可以使用 PYTHONPATH 環(huán)境變量來添加。例如:

bash % env PYTHONPATH=/some/dir:/other/dir python3
Python 3.3.0 (default, Oct 4 2012, 10:17:33)
[GCC 4.2.1 (Apple Inc. build 5666) (dot 3)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import sys
>>> sys.path
['', '/some/dir', '/other/dir', ...]
>>>

在自定義應用程序中,這樣的環(huán)境變量可在程序啟動時設置或通過 shell 腳本。

第二種方法是創(chuàng)建一個.pth 文件,將目錄列舉出來,像這樣:

# myapplication.pth
/some/dir
/other/dir

這個.pth 文件需要放在某個 Python 的 site-packages 目錄,通常位于/usr/local/lib/python3.3/site-packages 或者 ~/.local/lib/python3.3/sitepackages。當解釋器啟動時,.pth 文件里列舉出來的存在于文件系統(tǒng)的目錄將被添加到 sys.path。安裝一個.pth 文件可能需要管理員權限,如果它被添加到系統(tǒng)級的 Python 解釋器。

討論

比起費力地找文件,你可能會傾向于寫一個代碼手動調(diào)節(jié) sys.path 的值。例如:

import sys
sys.path.insert(0, '/some/dir')
sys.path.insert(0, '/other/dir')

雖然這能“工作”,它是在實踐中極為脆弱,應盡量避免使用。這種方法的問題是,它將目錄名硬編碼到了你的源。如果你的代碼被移到一個新的位置,這會導致維護問題。更好的做法是在不修改源代碼的情況下,將path配置到其他地方。如果您使用模塊級的變量來精心構造一個適當?shù)慕^對路徑,有時你可以解決硬編碼目錄的問題,比如file。舉個例子:

import sys
from os.path import abspath, join, dirname
sys.path.insert(0, abspath(dirname('__file__'), 'src'))

這將 src 目錄添加到 path 里,和執(zhí)行插入步驟的代碼在同一個目錄里。

site-packages 目錄是第三方包和模塊安裝的目錄。如果你手動安裝你的代碼,它將被安裝到 site-packages 目錄。雖然.pth 文件配置的 path 必須出現(xiàn)在 site-packages 里,但代碼可以在系統(tǒng)上任何你想要的目錄。因此,你可以把你的代碼放在一系列不同的目錄,只要那些目錄包含在.pth 文件里。

通過字符串名導入模塊

問題

你想導入一個模塊,但是模塊的名字在字符串里。你想對字符串調(diào)用導入命令。

解決方案

使用 importlib.import_module()函數(shù)來手動導入名字為字符串給出的一個模塊或者包的一部分。舉個例子:

>>> import importlib
>>> math = importlib.import_module('math')
>>> math.sin(2)
0.9092974268256817
>>> mod = importlib.import_module('urllib.request')
>>> u = mod.urlopen('http://www.python.org')
>>>

import_module 只是簡單地執(zhí)行和 import 相同的步驟,但是返回生成的模塊對象。你只需要將其存儲在一個變量,然后像正常的模塊一樣使用。

如果你正在使用的包,import_module()也可用于相對導入。但是,你需要給它一個額外的參數(shù)。例如:

import importlib
# Same as 'from . import b'
b = importlib.import_module('.b', __package__)

討論

使用 import_module()手動導入模塊的問題通常出現(xiàn)在以某種方式編寫修改或覆蓋模塊的代碼時候。例如,也許你正在執(zhí)行某種自定義導入機制,需要通過名稱來加載一個模塊,通過補丁加載代碼。

在舊的代碼,有時你會看到用于導入的內(nèi)建函數(shù)import()。盡管它能工作,但是 importlib.import_module() 通常更容易使用。

自定義導入過程的高級實例見10.11小節(jié)

通過鉤子遠程加載模塊

問題

你想自定義 Python 的 import 語句,使得它能從遠程機器上面透明的加載模塊。

解決方案

首先要提出來的是安全問題。本屆討論的思想如果沒有一些額外的安全和認知機制的話會很糟糕。 也就是說,我們的主要目的是深入分析 Python 的 import 語句機制。 如果你理解了本節(jié)內(nèi)部原理,你就能夠為其他任何目的而自定義 import。 有了這些,讓我們繼續(xù)向前走。

本節(jié)核心是設計導入語句的擴展功能。有很多種方法可以做這個, 不過為了演示的方便,我們開始先構造下面這個 Python 代碼結構:

testcode/
    spam.py
    fib.py
    grok/
        __init__.py
        blah.py

這些文件的內(nèi)容并不重要,不過我們在每個文件中放入了少量的簡單語句和函數(shù), 這樣你可以測試它們并查看當它們被導入時的輸出。例如:

# spam.py
print("I'm spam")

def hello(name):
    print('Hello %s' % name)

# fib.py
print("I'm fib")

def fib(n):
    if n < 2:
        return 1
    else:
        return fib(n-1) + fib(n-2)

# grok/__init__.py
print("I'm grok.__init__")

# grok/blah.py
print("I'm grok.blah")

這里的目的是允許這些文件作為模塊被遠程訪問。 也許最簡單的方式就是將它們發(fā)布到一個 web 服務器上面。在 testcode 目錄中像下面這樣運行 Python:

bash % cd testcode
bash % python3 -m http.server 15000
Serving HTTP on 0.0.0.0 port 15000 ...

服務器運行起來后再啟動一個單獨的 Python 解釋器。 確保你可以使用 urllib訪問到遠程文件。例如:

>>> from urllib.request import urlopen
>>> u = urlopen('http://localhost:15000/fib.py')
>>> data = u.read().decode('utf-8')
>>> print(data)
# fib.py
print("I'm fib")

def fib(n):
    if n < 2:
        return 1
    else:
        return fib(n-1) + fib(n-2)
>>>

從這個服務器加載源代碼是接下來本節(jié)的基礎。 為了替代手動的通過 urlopen() 來收集源文件, 我們通過自定義 import 語句來在后臺自動幫我們做到。

加載遠程模塊的第一種方法是創(chuàng)建一個顯示的加載函數(shù)來完成它。例如:

import imp
import urllib.request
import sys

def load_module(url):
    u = urllib.request.urlopen(url)
    source = u.read().decode('utf-8')
    mod = sys.modules.setdefault(url, imp.new_module(url))
    code = compile(source, url, 'exec')
    mod.__file__ = url
    mod.__package__ = ''
    exec(code, mod.__dict__)
    return mod

這個函數(shù)會下載源代碼,并使用 compile() 將其編譯到一個代碼對象中, 然后在一個新創(chuàng)建的模塊對象的字典中來執(zhí)行它。下面是使用這個函數(shù)的方式:

>>> fib = load_module('http://localhost:15000/fib.py')
I'm fib
>>> fib.fib(10)
89
>>> spam = load_module('http://localhost:15000/spam.py')
I'm spam
>>> spam.hello('Guido')
Hello Guido
>>> fib
<module 'http://localhost:15000/fib.py' from 'http://localhost:15000/fib.py'>
>>> spam
<module 'http://localhost:15000/spam.py' from 'http://localhost:15000/spam.py'>
>>>

正如你所見,對于簡單的模塊這個是行得通的。 不過它并沒有嵌入到通常的 import 語句中,如果要支持更高級的結構比如包就需要更多的工作了。

一個更酷的做法是創(chuàng)建一個自定義導入器。第一種方法是創(chuàng)建一個元路徑導入器。如下:

# urlimport.py
import sys
import importlib.abc
import imp
from urllib.request import urlopen
from urllib.error import HTTPError, URLError
from html.parser import HTMLParser

# Debugging
import logging
log = logging.getLogger(__name__)

# Get links from a given URL
def _get_links(url):
    class LinkParser(HTMLParser):
        def handle_starttag(self, tag, attrs):
            if tag == 'a':
                attrs = dict(attrs)
                links.add(attrs.get('href').rstrip('/'))
    links = set()
    try:
        log.debug('Getting links from %s' % url)
        u = urlopen(url)
        parser = LinkParser()
        parser.feed(u.read().decode('utf-8'))
    except Exception as e:
        log.debug('Could not get links. %s', e)
    log.debug('links: %r', links)
    return links

class UrlMetaFinder(importlib.abc.MetaPathFinder):
    def __init__(self, baseurl):
        self._baseurl = baseurl
        self._links = { }
        self._loaders = { baseurl : UrlModuleLoader(baseurl) }

    def find_module(self, fullname, path=None):
        log.debug('find_module: fullname=%r, path=%r', fullname, path)
        if path is None:
            baseurl = self._baseurl
        else:
            if not path[0].startswith(self._baseurl):
                return None
            baseurl = path[0]
        parts = fullname.split('.')
        basename = parts[-1]
        log.debug('find_module: baseurl=%r, basename=%r', baseurl, basename)

        # Check link cache
        if basename not in self._links:
            self._links[baseurl] = _get_links(baseurl)

        # Check if it's a package
        if basename in self._links[baseurl]:
            log.debug('find_module: trying package %r', fullname)
            fullurl = self._baseurl + '/' + basename
            # Attempt to load the package (which accesses __init__.py)
            loader = UrlPackageLoader(fullurl)
            try:
                loader.load_module(fullname)
                self._links[fullurl] = _get_links(fullurl)
                self._loaders[fullurl] = UrlModuleLoader(fullurl)
                log.debug('find_module: package %r loaded', fullname)
            except ImportError as e:
                log.debug('find_module: package failed. %s', e)
                loader = None
            return loader
        # A normal module
        filename = basename + '.py'
        if filename in self._links[baseurl]:
            log.debug('find_module: module %r found', fullname)
            return self._loaders[baseurl]
        else:
            log.debug('find_module: module %r not found', fullname)
            return None

    def invalidate_caches(self):
        log.debug('invalidating link cache')
        self._links.clear()

# Module Loader for a URL
class UrlModuleLoader(importlib.abc.SourceLoader):
    def __init__(self, baseurl):
        self._baseurl = baseurl
        self._source_cache = {}

    def module_repr(self, module):
        return '<urlmodule %r from %r>' % (module.__name__, module.__file__)

    # Required method
    def load_module(self, fullname):
        code = self.get_code(fullname)
        mod = sys.modules.setdefault(fullname, imp.new_module(fullname))
        mod.__file__ = self.get_filename(fullname)
        mod.__loader__ = self
        mod.__package__ = fullname.rpartition('.')[0]
        exec(code, mod.__dict__)
        return mod

    # Optional extensions
    def get_code(self, fullname):
        src = self.get_source(fullname)
        return compile(src, self.get_filename(fullname), 'exec')

    def get_data(self, path):
        pass

    def get_filename(self, fullname):
        return self._baseurl + '/' + fullname.split('.')[-1] + '.py'

    def get_source(self, fullname):
        filename = self.get_filename(fullname)
        log.debug('loader: reading %r', filename)
        if filename in self._source_cache:
            log.debug('loader: cached %r', filename)
            return self._source_cache[filename]
        try:
            u = urlopen(filename)
            source = u.read().decode('utf-8')
            log.debug('loader: %r loaded', filename)
            self._source_cache[filename] = source
            return source
        except (HTTPError, URLError) as e:
            log.debug('loader: %r failed. %s', filename, e)
            raise ImportError("Can't load %s" % filename)

    def is_package(self, fullname):
        return False

# Package loader for a URL
class UrlPackageLoader(UrlModuleLoader):
    def load_module(self, fullname):
        mod = super().load_module(fullname)
        mod.__path__ = [ self._baseurl ]
        mod.__package__ = fullname

    def get_filename(self, fullname):
        return self._baseurl + '/' + '__init__.py'

    def is_package(self, fullname):
        return True

# Utility functions for installing/uninstalling the loader
_installed_meta_cache = { }
def install_meta(address):
    if address not in _installed_meta_cache:
        finder = UrlMetaFinder(address)
        _installed_meta_cache[address] = finder
        sys.meta_path.append(finder)
        log.debug('%r installed on sys.meta_path', finder)

def remove_meta(address):
    if address in _installed_meta_cache:
        finder = _installed_meta_cache.pop(address)
        sys.meta_path.remove(finder)
        log.debug('%r removed from sys.meta_path', finder)

下面是一個交互會話,演示了如何使用前面的代碼:

>>> # importing currently fails
>>> import fib
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ImportError: No module named 'fib'
>>> # Load the importer and retry (it works)
>>> import urlimport
>>> urlimport.install_meta('http://localhost:15000')
>>> import fib
I'm fib
>>> import spam
I'm spam
>>> import grok.blah
I'm grok.__init__
I'm grok.blah
>>> grok.blah.__file__
'http://localhost:15000/grok/blah.py'
>>>

這個特殊的方案會安裝一個特別的查找器 UrlMetaFinder 實例, 作為 sys.meta_path中最后的實體。 當模塊被導入時,會依據(jù) sys.meta_path 中的查找器定位模塊。 在這個例子中,UrlMetaFinder實例是最后一個查找器方案, 當模塊在任何一個普通地方都找不到的時候就觸發(fā)它。

作為常見的實現(xiàn)方案,UrlMetaFinder 類包裝在一個用戶指定的URL上。 在內(nèi)部,查找器通過抓取指定URL的內(nèi)容構建合法的鏈接集合。 導入的時候,模塊名會跟已有的鏈接作對比。如果找到了一個匹配的, 一個單獨的 UrlModuleLoader 類被用來從遠程機器上加載源代碼并創(chuàng)建最終的模塊對象。 這里緩存鏈接的一個原因是避免不必要的 HTTP 請求重復導入。

自定義導入的第二種方法是編寫一個鉤子直接嵌入到sys.path變量中去, 識別某些目錄命名模式。 在 urlimport.py中添加如下的類和支持函數(shù):

# urlimport.py
# ... include previous code above ...
# Path finder class for a URL
class UrlPathFinder(importlib.abc.PathEntryFinder):
    def __init__(self, baseurl):
        self._links = None
        self._loader = UrlModuleLoader(baseurl)
        self._baseurl = baseurl

    def find_loader(self, fullname):
        log.debug('find_loader: %r', fullname)
        parts = fullname.split('.')
        basename = parts[-1]
        # Check link cache
        if self._links is None:
            self._links = [] # See discussion
            self._links = _get_links(self._baseurl)

        # Check if it's a package
        if basename in self._links:
            log.debug('find_loader: trying package %r', fullname)
            fullurl = self._baseurl + '/' + basename
            # Attempt to load the package (which accesses __init__.py)
            loader = UrlPackageLoader(fullurl)
            try:
                loader.load_module(fullname)
                log.debug('find_loader: package %r loaded', fullname)
            except ImportError as e:
                log.debug('find_loader: %r is a namespace package', fullname)
                loader = None
            return (loader, [fullurl])

        # A normal module
        filename = basename + '.py'
        if filename in self._links:
            log.debug('find_loader: module %r found', fullname)
            return (self._loader, [])
        else:
            log.debug('find_loader: module %r not found', fullname)
            return (None, [])

    def invalidate_caches(self):
        log.debug('invalidating link cache')
        self._links = None

# Check path to see if it looks like a URL
_url_path_cache = {}
def handle_url(path):
    if path.startswith(('http://', 'https://')):
        log.debug('Handle path? %s. [Yes]', path)
        if path in _url_path_cache:
            finder = _url_path_cache[path]
        else:
            finder = UrlPathFinder(path)
            _url_path_cache[path] = finder
        return finder
    else:
        log.debug('Handle path? %s. [No]', path)

def install_path_hook():
    sys.path_hooks.append(handle_url)
    sys.path_importer_cache.clear()
    log.debug('Installing handle_url')

def remove_path_hook():
    sys.path_hooks.remove(handle_url)
    sys.path_importer_cache.clear()
    log.debug('Removing handle_url')

要使用這個路徑查找器,你只需要在 sys.path 中加入 URL 鏈接。例如:

>>> # Initial import fails
>>> import fib
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
ImportError: No module named 'fib'

>>> # Install the path hook
>>> import urlimport
>>> urlimport.install_path_hook()

>>> # Imports still fail (not on path)
>>> import fib
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
ImportError: No module named 'fib'

>>> # Add an entry to sys.path and watch it work
>>> import sys
>>> sys.path.append('http://localhost:15000')
>>> import fib
I'm fib
>>> import grok.blah
I'm grok.__init__
I'm grok.blah
>>> grok.blah.__file__
'http://localhost:15000/grok/blah.py'
>>>

關鍵點就是 handle_url()函數(shù),它被添加到了 sys.path_hooks變量中。 當 sys.path的實體被處理時,會調(diào)用 sys.path_hooks中的函數(shù)。 如果任何一個函數(shù)返回了一個查找器對象,那么這個對象就被用來為 sys.path實體加載模塊。

遠程模塊加載跟其他的加載使用方法幾乎是一樣的。例如:

>>> fib
<urlmodule 'fib' from 'http://localhost:15000/fib.py'>
>>> fib.__name__
'fib'
>>> fib.__file__
'http://localhost:15000/fib.py'
>>> import inspect
>>> print(inspect.getsource(fib))
# fib.py
print("I'm fib")

def fib(n):
    if n < 2:
        return 1
    else:
        return fib(n-1) + fib(n-2)
>>>

討論

在詳細討論之前,有點要強調(diào)的是,Python 的模塊、包和導入機制是整個語言中最復雜的部分, 即使經(jīng)驗豐富的 Python 程序員也很少能精通它們。 我在這里推薦一些值的去讀的文檔和書籍,包括 importlib modulePEP 302. 文檔內(nèi)容在這里不會被重復提到,不過我在這里會討論一些最重要的部分。

首先,如果你想創(chuàng)建一個新的模塊對象,使用 imp.new_module() 函數(shù):

>>> import imp
>>> m = imp.new_module('spam')
>>> m
<module 'spam'>
>>> m.__name__
'spam'
>>>

模塊對象通常有一些期望屬性,包括 __file__ (運行模塊加載語句的文件名) 和 __package__ (包名)。

其次,模塊會被解釋器緩存起來。模塊緩存可以在字典 sys.modules 中被找到。 因為有了這個緩存機制,通常可以將緩存和模塊的創(chuàng)建通過一個步驟完成:

>>> import sys
>>> import imp
>>> m = sys.modules.setdefault('spam', imp.new_module('spam'))
>>> m
<module 'spam'>
>>>

如果給定模塊已經(jīng)存在那么就會直接獲得已經(jīng)被創(chuàng)建過的模塊,例如:

>>> import math
>>> m = sys.modules.setdefault('math', imp.new_module('math'))
>>> m
<module 'math' from '/usr/local/lib/python3.3/lib-dynload/math.so'>
>>> m.sin(2)
0.9092974268256817
>>> m.cos(2)
-0.4161468365471424
>>>

由于創(chuàng)建模塊很簡單,很容易編寫簡單函數(shù)比如第一部分的 load_module() 函數(shù)。 這個方案的一個缺點是很難處理復雜情況比如包的導入。 為了處理一個包,你要重新實現(xiàn)普通 import 語句的底層邏輯(比如檢查目錄,查找init.py 文件, 執(zhí)行那些文件,設置路徑等)。這個復雜性就是為什么最好直接擴展 import 語句而不是自定義函數(shù)的一個原因。

擴展 import 語句很簡單,但是會有很多移動操作。 最高層上,導入操作被一個位于 sys.meta_path 列表中的“元路徑”查找器處理。 如果你輸出它的值,會看到下面這樣:

>>> from pprint import pprint
>>> pprint(sys.meta_path)
[<class '_frozen_importlib.BuiltinImporter'>,
<class '_frozen_importlib.FrozenImporter'>,
<class '_frozen_importlib.PathFinder'>]
>>>

當執(zhí)行一個語句比如 import fib 時,解釋器會遍歷 sys.mata_path 中的查找器對象, 調(diào)用它們的find_module()方法定位正確的模塊加載器。 可以通過實驗來看看:

>>> class Finder:
...     def find_module(self, fullname, path):
...         print('Looking for', fullname, path)
...         return None
...
>>> import sys
>>> sys.meta_path.insert(0, Finder()) # Insert as first entry
>>> import math
Looking for math None
>>> import types
Looking for types None
>>> import threading
Looking for threading None
Looking for time None
Looking for traceback None
Looking for linecache None
Looking for tokenize None
Looking for token None
>>>

注意看 find_module()方法是怎樣在每一個導入就被觸發(fā)的。 這個方法中的 path 參數(shù)的作用是處理包。 多個包被導入,就是一個可在包的__path__ 屬性中找到的路徑列表。 要找到包的子組件就要檢查這些路徑。 比如注意對于 xml.etreexml.etree.ElementTree 的路徑配置:

>>> import xml.etree.ElementTree
Looking for xml None
Looking for xml.etree ['/usr/local/lib/python3.3/xml']
Looking for xml.etree.ElementTree ['/usr/local/lib/python3.3/xml/etree']
Looking for warnings None
Looking for contextlib None
Looking for xml.etree.ElementPath ['/usr/local/lib/python3.3/xml/etree']
Looking for _elementtree None
Looking for copy None
Looking for org None
Looking for pyexpat None
Looking for ElementC14N None
>>>

sys.meta_path上查找器的位置很重要,將它從隊頭移到隊尾,然后再試試導入看:

>>> del sys.meta_path[0]
>>> sys.meta_path.append(Finder())
>>> import urllib.request
>>> import datetime

現(xiàn)在你看不到任何輸出了,因為導入被 sys.meta_path 中的其他實體處理。 這時候,你只有在導入不存在模塊的時候才能看到它被觸發(fā):

>>> import fib
Looking for fib None
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
ImportError: No module named 'fib'
>>> import xml.superfast
Looking for xml.superfast ['/usr/local/lib/python3.3/xml']
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
ImportError: No module named 'xml.superfast'
>>>

你之前安裝過一個捕獲未知模塊的查找器,這個是 UrlMetaFinder 類的關鍵。 一個 UrlMetaFinder 實例被添加到 sys.meta_path 的末尾,作為最后一個查找器方案。 如果被請求的模塊名不能定位,就會被這個查找器處理掉。 處理包的時候需要注意,在 path 參數(shù)中指定的值需要被檢查,看它是否以查找器中注冊的 URL 開頭。 如果不是,該子模塊必須歸屬于其他查找器并被忽略掉。

對于包的其他處理可在 UrlPackageLoader 類中被找到。 這個類不會導入包名,而是去加載對應的 __init__.py 文件。 它也會設置模塊的 __path__ 屬性,這一步很重要, 因為在加載包的子模塊時這個值會被傳給后面的 find_module() 調(diào)用。 基于路徑的導入鉤子是這些思想的一個擴展,但是采用了另外的方法。 我們都知道,sys.path 是一個 Python 查找模塊的目錄列表,例如:

>>> from pprint import pprint
>>> import sys
>>> pprint(sys.path)
['',
'/usr/local/lib/python33.zip',
'/usr/local/lib/python3.3',
'/usr/local/lib/python3.3/plat-darwin',
'/usr/local/lib/python3.3/lib-dynload',
'/usr/local/lib/...3.3/site-packages']
>>>

sys.path 中的每一個實體都會被額外的綁定到一個查找器對象上。 你可以通過查看 sys.path_importer_cache 去看下這些查找器:

>>> pprint(sys.path_importer_cache)
{'.': FileFinder('.'),
'/usr/local/lib/python3.3': FileFinder('/usr/local/lib/python3.3'),
'/usr/local/lib/python3.3/': FileFinder('/usr/local/lib/python3.3/'),
'/usr/local/lib/python3.3/collections': FileFinder('...python3.3/collections'),
'/usr/local/lib/python3.3/encodings': FileFinder('...python3.3/encodings'),
'/usr/local/lib/python3.3/lib-dynload': FileFinder('...python3.3/lib