妙用Hook來研究Python的Import機(jī)制
這兩天周末在家學(xué)習(xí)Python,我發(fā)現(xiàn)我們平常接觸最多的也就是import這條語句,這兩天在編寫一些程序的時(shí)候恰恰需要import hook去完成一些操作,借著這個(gè)周末在家閑著沒事兒通過import hook這個(gè)命令,把Python的import機(jī)制了解了一下。
0x00 Import機(jī)制概述
從名字上可以推斷出,import hook這個(gè)命令是和Python的導(dǎo)入機(jī)制有所關(guān)聯(lián)。再具體一點(diǎn)的話,import hook的作用是把我們自己寫的腳本直接注入到Python導(dǎo)入的例行操作里去。如果還要繼續(xù)往下說的話,那我們首先應(yīng)該來了解一下import默認(rèn)的時(shí)候是如何處理的。
對(duì)于我們來說的話,其實(shí)這個(gè)過程比較簡(jiǎn)單:當(dāng)Python的解釋器遇到import語句的時(shí)候,它回去查閱sys.path里面所有已經(jīng)儲(chǔ)存的目錄。這個(gè)列表初始化的時(shí)候,通常包含一些來自外部的庫(external libraries)或者是來自操作系統(tǒng)的一些庫,當(dāng)然也會(huì)有一些類似于dist-package的標(biāo)準(zhǔn)庫在里面。這些目錄通常是被按照順序或者是直接去搜索想要的:如果說他們當(dāng)中的一個(gè)包含有期望的package或者是module,這個(gè)package或者是module將會(huì)在整個(gè)過程結(jié)束的時(shí)候被直接提取出來。
我們可以寫一段代碼來演示一下ImportError,運(yùn)行下面的代碼的時(shí)候,我們會(huì)catch一個(gè)exception,在程序結(jié)束之前,它可能會(huì)嘗試多個(gè)imports。
- #!/usr/bin/env python
- #coding=utf8
- try:
- # Python 2.7-3.x
- import json
- except ImportError:
- try:
- # Python 2.6
- import simplejson as json
- except ImportError:
- try:
- from django.utils import simplejson as json
- except ImportError:
- raise Exception("Requires a JSON package!")
雖然說這段sample寫的很不beautiful,但是他可以在一定程度上增加我們寫的程序或者package的可以執(zhí)行。慶幸的是我們僅僅需要用這種方式去處理極少數(shù)有價(jià)值的庫,比如說代碼中的Json庫。
0x01 關(guān)于__path__的更多細(xì)節(jié)
上文中提到的Python的Import流在大多數(shù)情況下是想描述一樣有用的,但是事實(shí)上遠(yuǎn)不止這些。他省略了一些我們可以根據(jù)需要調(diào)節(jié)的地方。
首先,__path__這個(gè)屬性是我們可以在__init__.py里面去定義的。你可以認(rèn)為他像一個(gè)sys.path的本地?cái)U(kuò)展并且只服務(wù)于我們導(dǎo)入的package的子模塊。換句話說,它包含目錄時(shí)應(yīng)該尋找一個(gè)package的子模塊被導(dǎo)入。默認(rèn)的情況下只有__init__.py的目錄,但是他可以擴(kuò)展到包含任何其他任何的路徑。
舉一個(gè)典型的例子就是把一些邏輯上的package分割成多個(gè)實(shí)際上的package,其實(shí)就是分割成多個(gè)distribution,一般情況下是不同的pypi包。舉個(gè)例子,讓我們假設(shè)構(gòu)造一個(gè)test.package,里面包含有test.client和test.server,他們?cè)趐ypi注冊(cè)的時(shí)候是按照兩個(gè)不同的distribution去注冊(cè)的,這樣的話用戶可以選擇其中的一個(gè)或多個(gè)distribution去安裝。我們需要設(shè)置test.__path__讓他們?nèi)ブ赶騮est.server和test.client的目錄(如果你只安裝了一個(gè)distribution的話只需要設(shè)置一個(gè))。聽上去好像有點(diǎn)復(fù)雜,實(shí)際上Python有一個(gè)模塊叫做pkgutil,這個(gè)模塊的作用就是讓我們很輕松的去實(shí)現(xiàn)上述的功能,你只需要在test/__init__.py下面添加一下兩行就可以了。
- import pkgutil
- __path__ = pkgutil.extend_path(__path__, __name__)
其實(shí)還有比這個(gè)還簡(jiǎn)單的方法,這里推薦一個(gè)文章給大家:http://doughellmann.com/PyMOTW/
0x02 真·鉤子:sys.meta_path和sys.path_hooks
讓我們繼續(xù),接著我們就會(huì)去分析import的過程,其實(shí)這部分正是這篇文章的重點(diǎn)。截下來說的比如說從zip文件或者是repo里面字節(jié)獲取模塊,或者是動(dòng)態(tài)的去用各種方法建立它們,比如說是web服務(wù)、dll或者是RESTful API等等幾乎你可以想到的任何的方法。我也會(huì)提到一些各個(gè)獨(dú)立模塊之間拿坑爹的交互性,比如說一個(gè)package檢測(cè)到自己被導(dǎo)入的時(shí)候,它能夠適應(yīng)和擴(kuò)展自己的接口。接著我們將會(huì)討論一下Python的安全增強(qiáng)沙箱,這個(gè)沙箱的作用是用來拒絕訪問某些模塊或者是改變其某些功能。
這些功能其實(shí)都可以通過import hooks來實(shí)現(xiàn)。有兩種不同的hook,一種叫做meta hook(sys.meta_path),另一種叫做path hook(sys.path_hooks)。盡管他們?cè)趦蓚€(gè)差不多的導(dǎo)入流的階段被調(diào)用,但是他們被創(chuàng)建的時(shí)候還是會(huì)取決于兩個(gè)東西,一個(gè)叫做模塊查找器(Module Finder),一個(gè)叫做模塊加載器(Module Loader)。
模塊查找器其實(shí)是一種簡(jiǎn)單的用來查找模塊的對(duì)象,他(find_module)的使用方法如下面所示:
- finder.find_module(fullname, path=None)
他需要把一個(gè)完整的模塊的名字當(dāng)做參數(shù)傳進(jìn)去,path則為這個(gè)模塊的路徑。這個(gè)對(duì)象的可以完成以下三件事中的任意一件:
- 拋出一個(gè)異常,然后完全取消所有的導(dǎo)入流程
- 返回一個(gè)None,意思是被導(dǎo)入的這個(gè)模塊不能夠被這個(gè)查找器所找到。但是他仍然可以被導(dǎo)入流的下一個(gè)階段所找到,比如說一些自定義的查找器或者是Python的標(biāo)準(zhǔn)導(dǎo)入機(jī)制。
- 返回一個(gè)加載器對(duì)象用來加載實(shí)際的模塊。
下一個(gè)就是模塊加載器,模塊加載器其實(shí)就是一個(gè)用來加載制定模塊的對(duì)象,它(load_module)的使用方法如下面的代碼所示:
- loader.load_module(fullname)
這里需要在強(qiáng)調(diào)一次,fullname參數(shù)需要傳進(jìn)去一個(gè)我們想要加載的模塊的全名。返回值應(yīng)當(dāng)是一個(gè)模塊的對(duì)象,***的結(jié)果當(dāng)然就是完成導(dǎo)入對(duì)象的操作。需要注意的是,這些模塊可能已經(jīng)被導(dǎo)入了,或者是復(fù)制這些模塊的功能用來返回這些已經(jīng)存在的模塊。下面是這個(gè)函數(shù)的原型:
- def load_module(self, fullname):
- if fullname in sys.modules: return sys.modules[fullname]
如果在這一階段出現(xiàn)了任何錯(cuò)誤,模塊加載器應(yīng)該拋出一個(gè)ImportError的異常
0x03 自己構(gòu)造一個(gè)加載器:
上面這些僅僅是一些理論,其實(shí)吧PEP302標(biāo)準(zhǔn)里面都描述了這些。在實(shí)際當(dāng)中,其實(shí)模塊加載器和模塊查找器可以是同一個(gè)對(duì)象,也就是說find_module可以去return self。舉個(gè)例子,其實(shí)這個(gè)簡(jiǎn)單的hook可以去阻止任何特定的模塊被導(dǎo)入:
- #!/usr/bin/env python
- #coding=utf8
- import sys
- class ImportBlocker(object):
- def __init__(self, *args):
- self.module_names = args
- def find_module(self, fullname, path=None):
- if fullname in self.module_names:
- return self
- return None
- def load_module(self, name):
- raise ImportError("%s is blocked and cannot be imported" % name)
- sys.meta_path = [ImportBlocker('httplib')]
一旦我們?cè)趕ys.meta_path中加載了這個(gè)hook,他就會(huì)去阻止任何導(dǎo)入的新模塊并且檢查他是否存在于我們的列表里。如果我們?nèi)ナ褂肦equest庫的時(shí)候,這個(gè)hook也會(huì)同樣起作用。
Import Request
執(zhí)行這條語句會(huì)失敗,因?yàn)閞equest是在urllib3內(nèi)部使用的,進(jìn)而去限制httplib的使用。但是一個(gè)hook要是沒事兒干總?cè)r截調(diào)用別的模塊似乎沒啥太大的意思,咱們換個(gè)別的玩法。如果說總是拒絕調(diào)用特定的模塊,我們?yōu)樯恫挥靡粋€(gè)warning去代替呢?這樣的話,這個(gè)hook就可以幫我們檢測(cè)被導(dǎo)入到項(xiàng)目當(dāng)中又被棄用的模塊。代碼如下:
- # !/usr/bin/env python
- # coding=utf-8
- import logging
- import imp
- import sys
- class WarnOnImport(object):
- def __init__(self, *args):
- self.module_names = args
- def find_module(self, fullname, path=None):
- if fullname in self.module_names:
- self.path = path
- return self
- return None
- def load_module(self, name):
- if name in sys.modules:
- return sys.modules[name]
- module_info = imp.find_module(name, self.path)
- module = imp.load_module(name, *module_info)
- sys.modules[name] = module
- logging.warning("Imported deprecated module %s", name)
- return module
- sys.meta_path = [WarnOnImport('getopt', 'optparse')]
為了去訪問一個(gè)正常的導(dǎo)入機(jī)制,我們可以嘗試使用imp。它的find_module和load_module函數(shù)和我們要導(dǎo)入的hook具有相同的名字。但是imp提供的功能更強(qiáng)大,比如說還包括了load_source和load_compile這些功能甚至可以從頭來初始化一個(gè)模塊(new_module)。