以相對路徑新增 Python Module Search Path,誰在呼叫我?

在 Python 裡,執行期動態修改 module search path 的做法似乎很常見。例如:

sys.path.append('../common')

這種做法存在著一些風險:

  • 當 search path 用相對路徑來表示時,其實是相對於 current working directory (CWD);只要 CWD 在執行期一經變動(os.chdir()),相對路徑就無法被解讀成正確的位置。
  • 其他 module 也可能用相同的方式將同一個路徑加到 search path 裡,最後 search path 裡就會有一堆重複的路徑。

現實的問題是,當系統以外部的 .py 做為擴充的手段時,通常這些 .py 無法從 search path 裡找到。而這些外部的程式一旦要做模組化時(通常是將共用的邏輯提出成為可以共用的 module),就不得不在執行期修改 search path,而且在這種情況下,相對路徑的表示法會比絕對表示法來得直覺。

如果我們想要一個方便的 function 用來修改 search path,而且又可以用相對於該 module 的方式來描述位置時,首先必須要知道是誰在呼叫這個 function;關於這一點,Python 內建的 inspect 可以幫上忙…

Note

誰在呼叫我?

如果我可以知道是誰在呼叫我,那麼對方就能以他的立場來描述一些事情,而我也能聽得懂對方在說什麼…

/tmp/foo.py

import os
print 'cwd (before):', os.getcwd()
os.chdir('/')
print 'cwd (after):', os.getcwd()
print 'foo:', __file__

import bar
bar.call_me()

/tmp/bar.py

import inspect
print 'bar: ', __file__

def call_me():
    frame_records = inspect.stack() 1
    print frame_records

    frame = frame_records[1][0]
    print 'bar.call_me:', inspect.getfile(frame) 2
1 基本上,inspect.stack() 會傳回 list of tuple (frame records)。
2 inspect.getfile() 取得 caller 所屬的 module。

執行結果:

$ python foo.py
cwd (before): /tmp
cwd (after): /
foo: foo.py       1
bar:  /tmp/bar.py 2
[(<frame object at 0x92d6554>, '/tmp/bar.py', 5, 'call_me', ['    frame_records = inspect.stack()\n'], 0),
 (<frame object at 0x926cc84>, 'foo.py', 8, '<module>', None, None)] 3
bar.call_me: foo.py
1 Main script 的 __file__ 只記錄檔名,位置是相對於一開始的 CWD,也就是 main script 所在的目錄。
2 非 main script 的 __file__ 記錄著絕對路徑,不論之後 CWD 怎麼切換。
3 第二個元素(frame_records[1])才是代表 caller。

於是 sys_path_append(path) 因此誕生:

pathutils.py

import os, sys, logging, inspect

_logger = logging.getLogger(__name__)

def sys_path_append(path): 1
    """Add a new path to the Python module search path.

    The path can also be relative to the module calling this function.

    """

    abspath = path
    if not os.path.isabs(path):
        caller_file = inspect.getfile(inspect.stack()[1][0])
        if os.path.isabs(caller_file):
            basedir = os.path.dirname(caller_file)
        else: # called by a main script
            basedir = sys.path[0] 2
        abspath = os.path.normpath(os.path.join(basedir, path))

    append = abspath not in sys.path
    _logger.info("Trying to add path '%s' -> '%s' to the module search path; append = %s, sys.path = %s", path, abspath, append, sys.path)

    if append: sys.path.append(abspath)
1 取這個名字是為了跟 sys.path.append() 的用法相呼應,方便換成 sys_path_append()
2 Main script 的路徑預設是 search path 的第一筆。

做一個簡單的測試:(假設 search path 包今 pathutils.py 所在的路徑)

/tmp/common/mylib.py

def brabra():
    print 'mylib: bra bra ...'

/tmp/extension/foobar.py

import sys
sys.path.append('../common')

import mylib
def do_something():
    mylib.brabra()

/tmp/extension/test.py

import foobar
foobar.do_something()

測試結果:

$ pwd
/tmp/extension
$ python test.py
mylib: bra bra ... 1

顯然 sys.path.append('../common') 的寫法沒問題。但如果程式執行的過程中,有人修改了 CWD 呢?

/tmp/extension/test.py

import os
os.chdir('/') 1

import foobar
foobar.do_something()
1 先把 CWD 切到其他地方。

再做一次相同的測試:

$ python test.py
Traceback (most recent call last):
  File "test.py", line 4, in <module>
    import foobar
  File "/tmp/extension/foobar.py", line 4, in <module>
    import mylib
ImportError: No module named mylib 1
1 問題出在以新的 CWD (/) 為起點,/../common 的位置不是你要的(也不存在 :p)。

這時候 sys_path_append() 就可以幫上忙了:(test.py 維持不變)

/tmp/extension/foobar.py

#import sys
#sys.path.append('../common')
from pathutils import sys_path_append
sys_path_append('../common') 1

import mylib
def do_something():
    mylib.brabra()
1 將原來的 sys.path.append('../common') 置換成 sys_path_append('../common') 即可。

測試結果:

$ python test.py
No handlers could be found for logger "pathutils"
mylib: bra bra ...

從此不怕 CWD 怎麼切換,就是能用相對路徑調整 search path!

相同的原理,也可以用來寫一個將相對路徑轉成絕對路徑的 helper function:

def abspath(relpath, module=None):
    if os.path.isabs(relpath): return relpath

    caller_file = module.__file__ if module else inspect.getfile(inspect.stack()[1][0])
    if os.path.isabs(caller_file):
        basedir = os.path.dirname(caller_file)
    else: # called by a main script
        basedir = sys.path[0]

    return os.path.normpath(os.path.join(basedir, relpath))
Note

參考資料

廣告

發表迴響

在下方填入你的資料或按右方圖示以社群網站登入:

WordPress.com Logo

您的留言將使用 WordPress.com 帳號。 登出 / 變更 )

Twitter picture

您的留言將使用 Twitter 帳號。 登出 / 變更 )

Facebook照片

您的留言將使用 Facebook 帳號。 登出 / 變更 )

Google+ photo

您的留言將使用 Google+ 帳號。 登出 / 變更 )

連結到 %s