Linux 下以 Python scripting 輔助系統管理的技巧

author:Yung-Yu Chen (yungyuc) http://blog.seety.org/everydaywork/ <yyc@seety.org>
copyright:© 2006, all rights reserved

目錄

1   Linux、指令稿與 Python

對 Linux 來說,指令稿 (script) 是至為重要的部分。在主要的 Linux distribution 中間,從系統的啟動到運作,都離不開 shell 指令稿撰寫。在我的主機上面執行一下:

$ ls /usr/bin/* /bin/* | wc -l
2585
$ file /usr/bin/* /bin/* | grep "shell script" | wc -l
267

看,可以找到 267 個 shell 指令稿程式,超過 /usr/bin/usr 目錄下所有 (程式) 檔案的十分之一。這還只是 shell 指令稿的部分而已。

在一個像 Linux 這樣以檔案為操作導向的作業系統上面,script 的活躍是理所當然的事情。絕大部分的系統設定都以字串的形式寫在組態檔裡面,而作業系統的執行期資訊也存在檔案系統之中 (/proc);直接處理這些字串就能管理系統,用指令稿語言來進行自動化是非常合適的。

像 Python 這種指令稿語言因為開發快速的關係,能夠很快地製作出我們想要的系統管理功能出來。除了開發快速之外,Python 也具有容易維護的特性。相比之下,Perl 程式雖然可以寫得更短,但也更不容易看懂;shell 指令稿則不是完整的開發環境。Python 是撰寫系統管理指令稿的理想工具。

2   Python 指令稿的格式

Python 指令稿與其它語言的指令稿的基本格式完全一樣,本身都是純文字檔,而在檔頭要以 #! 指定直譯程式的位置:

#!/usr/bin/python
print "Hello, world!"

這是我們上一期寫過的 hello.py 程式,不要忘記 chmod a+x hello.py,如此便可以在指令行下執行這個指令稿:

$ ./hello.py
Hello, world

我們習慣上會給 Python 程式取個副檔名 .py,但 Linux 的指令稿並不需要綴上副檔名;把 hello.py 改成 hello,程式一樣會正常執行。.py 副檔名對 Python 仍有特別的意義,但只在撰寫 Python 模組的時候才有用處。

對於指定 Python 直譯器標頭,我們一般有兩種作法。像以上的 hello.py 這種寫為絕對路徑的方式其實並非必要,我們可以改用相對路徑的方式來指定:

#!/usr/bin/env python

於是會以 /usr/bin/env 程式來叫用 python 直譯器,處理 Python 程式檔案。這麼作的好處是當系統中安裝有許多個不同的 Python 直譯器時,會採用路徑在最前面的那一個。如此一來,若使用者另外安裝了一版 Python (例如裝在自己的家目錄),又把自己的 Python 放到路徑設定 (PATH 環境變數) 的最前面,即會採用使用者自己安裝的 Python。

每一版 Python 除了有 python 這個執行檔之外,還會附有內容完全相同的 pythonX.Y 這個執行檔,X.Y 是該版 Python 的 major version 和 minor version。譬如 Python 2.3 就會有 pythonpython2.3 這兩個直譯器,用起來是完全一樣的。如果我們寫的指令稿程式必須要使用某一個版本的 Python,可以偷偷在指令稿標頭上動手腳來進行限制;以 Python 2.3 為例,就把標頭寫成:

#!/usr/bin/env python2.3

註記

Python 提供了一套正統的方法來檢查所使用 Python 及所有相關環境的資訊。在指令稿標頭上動手腳雖然方便,但不是保險的正統作法;只是,若程式本身就沒多長 (譬如說二三十行),的確不必浪費時間去寫一串檢查程式。

當指令稿只使用了主流版號的標準程式庫時 (這是一般的狀況),通常就不必指定 Python 的版本。

若寫成 hello.py 裡那種絕對路徑的標頭,就會限定使用安裝在某一個位置的 Python。通常我們都會指定在 /usr/bin/python/usr/bin/pythonX.Y (看要指定哪一版),即系統所安裝的 Python;寫成這樣的話,使用者就不好改用自己安裝的版本了。

Python 直譯器還會讀取另一組格式為 # -*- setting -*- 的標頭 (通常接在第一行以後),其中常用的是:

# -*- coding: UTF-8 -*-

用途是指定「指令稿檔案內純文字的字元編碼 (為 UTF-8)」。如果你想要寫中文註解,這就非常重要;Python 自己有一套字元編碼轉換的機制,實作在 codecs 模組裡面,但直到 Python 2.4 之前,繁體中文常用的 Big5 編碼並未進入標準的 codecs 模組。如果指令稿檔案使用了 Python 看不懂的字元編碼 (就是指華文世界用的 Big5 和 GB),程式雖然仍可執行,但 Python 直譯器會送出警告。如果想用中文撰寫註解,最好把指令稿檔案轉為 UTF-8 Unicode,並如上指定編碼。

上一期已經提過了,Python 也是以 # 當作單行註解符號的 (和 shell script 一樣);所有在這個符號之後的文字都是註解。順帶一提,如果你習慣以 VIM 編輯 Python 指令稿,可以在檔尾加上 VIM 的設定字串:

# vim:set nu et ts=4 sw=4 cino=>4:

設定顯示行號 (nu)、展開跳格鍵 (et,對 Python 程式來說,跳格鍵 Tab 是最要不得的東西),指定跳格字元為 4 (ts=4)、偏移字元寬為 4 (sw=4)、C 式縮排為 >4 (cino=>4);然後再打開語法標示 (syntax highlighting,這個在 .vimrc 裡設定比較合適)。使用這樣的編輯環境,對撰寫 Python 程式來說會很方便。

Python 直譯器會依出現順序來執行程式碼檔案裡的指令。如果我們想撰寫比較具組織性的指令稿,可以把平舖直述的:

print "some operations"

改成這樣的程式碼結構:

def main():
    print "some operations"

if __name__ == '__main__':
    main()

亦即自行製作一個「進入點」 main() 函式。當指令稿比較長 (超過一百行以上),以及將來在擴充指令稿的時候,就會比較方便。

總結來說,一個 Python 指令稿的常見格式應為:

#!/usr/bin/env python
# -*- coding: UTF-8 -*-

def main():
    print "Hello, world"

if __name__ == '__main__':
    main()

# vim:set nu et ts=4 sw=4 cino=>4:

3   字串處理

在管理 Linux 系統時,(純文字) 設定檔案以及其中的字串處理是至為核心的部分;讓我們來看看 Python 如何進行這些工作。因為我們在上一期已經用 Python 處理過字串和檔案了,所以在這裡,我們應該對字串處理作深入一點的介紹。

首先我們要知道的是,字串在 Python 裡面是一種物件。打開 Python 互動式環境 (到 shell 去執行 python 即可進入),執行以下動作:

>>> print type( "" )
<type 'str'>
>>> if type( "I am a string" ) is str: print True
...
True
>>> if type( "Another string" ) is str(): print True
...

type() 是 Python 的內建函式,用來取得變數的型態。我們可以從這三個指令看出來,字串 "", "I am a string" 都是 str 類別的物件。查看 Python 的線上文件,會發現有兩組關於字串處理的程式庫;一組是 string 模組裡的函式,另一組則是字串物件專用的方法 (String Methods)。兩者雖有一些差別,但功能的重覆性相當高;我們討論的重點在字串方法。

我們常常會需要分析檔案中的字串:把字串拆解開來,依照給定的邏輯來判斷字串資料的意義。因此,最常用的字串方法就是我們上一期有用到的 split()。split() 傳回的是列表 (list),可以用索引值 (以 0 起始) 來存取列表中的各個項目。再來示範一下:

>>> tokens = "This is a sample string used to demo split()".split()
>>> len(tokens)
9
>>> print tokens
['This', 'is', 'a', 'sample', 'string', 'used', 'to', 'demo', 'split()']
>>> print tokens[0], tokens[2]
This a
>>> print tokens[-1], tokens[-2]
split() demo
>>> print tokens[2:5]
['a', 'sample', 'string']

第一個指令把我們的字串切成了 9 個字串,存在 tokens 這個列表裡。len() 是個內建函式,用來量測像列表這種可以存放其它東西的物件的長度 (傳回所包含的項目個數)。列表只要是整數就可以了,但最大不能到項目個數;可以給入負值,表示從列表尾端開始計算。索引值 -1 即為列表的最後一個項目。

有辦法切開字串進行判斷了之後,我們常常還需要把分析結果給輸出出來,那麼就得接合字串;以字串的格式化操作 (string format operations) 就能完成這件工作。我們可以寫出以下的表示式:

>>> "%d %f %s" % (1, 1.2, "string")
'1 1.200000 string'

這就是字串格式化操作。以帶有特別轉換字元 (conversion character) 的格式化字串,後接 % 運算子,再接一個 tuple 作為參數,就能把 tuple 裡的資料填進格式化字串裡去。常用的 %d 代表有號整數、%f 代表浮點數、%s 代表字串,完整的轉換字元表請參考 Python 的線上文件。

註記

Python 的 tuple 也是一種可以包含其它物件的資料結構,以整數索引存取其中的物件,但其行為與列表不盡相同。在語法上,tuple 用 (1, 2, 3) 來宣告,而列表用 [1, 2, 3] 來宣告。如果 tuple 中只有一個物件,則要寫成 (1,),不要忘記右括號前的逗號。在字串格式化操作時,若轉換字元只有一個,% 運算元後的 tuple 也可以用單一變數來代替。

字串物件另有一個叫作 join() 的方法可以用來結合字串,用法如下:

>>> "".join([ "a", "b", "c" ])
'abc'
>>> "-".join([ "a", "b", "c" ])
'a-b-c'

在處理字串時,最後要注意的是,Python 的字串不可變。也就是說,想變更字串中的某一個字元,不能直接設:

>>> a = "write"
>>> a[2] = "o"
Traceback (most recent call last):
  File "<stdin>", line 1, in ?
TypeError: object doesn't support item assignment

那是不合法的。那該怎麼辦呢?可以這樣作:

>>> print a[:2]+"o"+a[3:]
wrote

字串的內容雖然不能變更,但字串本身可以加起來 (串接)。a[:2] 表示取出 a 字串到索引 2 為止的部分;a[3:] 表示取出 a 字串從索引 3 開始到結尾的部分;然後在中間接入 "o"。最後我們還是可以得到 wrote 字串。這種操作索引的技巧,也可以用在一般的列表上。

Python 同樣具有常規表示式 (regular expression) 的操作能力,實作在 re 模組裡面。用來執行字串取代是非常方便的。

3.1   轉換字元編碼

Python 有一套處理字元編碼的 codecs 模組;我們以之即可自由地將字元轉換為各種不同的編碼,這是我們在處理多國語言資料時常需處理的問題。然而,字串物件本身就提供有 encode()decode() 方法,我們不必匯入 codecs 模組就可以使用這兩個方法為我們提供的 codecs 能力。

此處我們得要注意一個事實,那就是 Python 擁有兩種字串物件。其一是我們剛剛一直在處理的 str 字串,而另一種呢,就是對多國語言處理非常重要的 unicode 字串。一般我們用引號或雙引號表示的都是普通的字串 (str),而用 u"string" 表示的呢,就是 unicode 字串。decode() 能把普通字串解碼成 unicode 物件,而 encode() 則能把 unicode 物件編碼成各種支援的字元集。

在處理中文編碼之前,我們要為 Python 2.3 安裝相關的外加套件:cjkcodecs 與 iconvcodecs;前者是中日韓專用的 codecs 物件,而後者允許 Python 直接使用 GNU iconv 工具所提供的編碼,作為 codecs 物件。假設我們得把原本是 Big5 的編碼重編為 UTF-8,那麼可以這樣作:

>>> f = open( "file.big5" )
>>> s = f.read()
>>> f.close()
>>> sp = s.decode('Big5').encode('UTF-8')

你可以在電腦上找一個內容是 Big5 編碼的檔案,把 locale 改成 UTF-8,然後在 Python 互動式環境下執行以上的指令 (該改的地方請改一下)。最後再用 print s, sp 比較一下轉換前後的字串。

4   檔案系統與目錄

在 Linux 系統中複製、搬移、刪除檔案與目錄也是管理時常見的動作。Python 提供的 os 模組能處理作業系統所支援的大部分檔案系統操作,另外還有 shutil 模組,提供更高階的操作。

4.1   檔案系統操作

檔案系統與檔案內容是不一樣的議題。我們在進行檔案系統操作時,處理的是搬移 (更名)、複製與刪除,比較沒有機會直接新增檔案。這些動作在 osshutil 模組裡幾乎都有提供;我們應該先匯入這兩個模組。

若要複製檔案,我們可以這樣作:

>>> shutil.copy('data.txt', 'data.new.txt')

刪除檔案則用 os.unlink()

>>> os.unlink('data.new.txt')

搬移 (更名) 有兩種方法:

>>> os.rename('data.txt', 'data.alter.txt')
>>> shutil.move('data.alter.txt', 'data.txt')

第一種方法,若來源檔 (第一個參數) 與目的檔不在同一個檔案系統內 (分割區),此動作可能會失效 (不同的 Unix 有不同的處理方法)。第二種方法比較高階,無論來源檔與目的檔是否在相同的檔案系統內,都可以使用。

4.2   路徑的處理

管理系統的時候多半不會只處理當前目錄內的檔案,所以常要對路徑字串進行處理。os.path 模組提供了處理路徑的函式,常用的有:

  • abspath():接受一個路徑字串,傳回該路徑所代表的絕對路徑。
  • realpath():接受一個路徑字串,計算該路徑中包含的符號連結 (symbolic link),傳回所代表的真正路徑。
  • split(), dirname(), basename()split() 接受一個路徑字串,從最後一個路徑項目前切開,分成包含該項目的目錄與該項目名本身,以 tuple 傳回。dirname()split() 傳回值的第一個元素;basename() 是第二個元素。
  • join():接受一個路徑列表,把該列表中的每個元素接成一個完整路徑字串後傳回。
  • splitext():接受一個路徑字串,分開其副檔名,將主檔名與副檔名用一個 tuple 傳回。
  • exists():測試傳入的路徑字串是否存在,傳回布林值。
  • isfile(), isdir(), islink(), isabs():分別用來測試所傳入的路徑字串是否為檔案、目錄、符號連結或絕對路徑;傳回布林值。

實際要使用的時候,大概會像是這樣子:

>>> os.path.split( "a/b/c" )
('a/b', 'c')
>>> os.path.join( "a", "b", "c" )
'a/b/c'
>>> os.path.splitext( "dir/file.ext" )
('dir/file', '.ext')

你可以在你的目錄結構裡,用真正的路徑來試試看!

5   外部程式呼叫

許多在 shell 指令稿中要靠呼叫外部程式才能完成的作業,都能用 Python 的內建模組來完成,例如上面提到的字串處理、檔案處理、目錄處理等等。而若遇到 Python 不足的地方,或是有非常特別的操作,當然也可以呼叫外部的程式。

os 模組有一個 system() 函式可以用來呼叫外部程式:

>>> os.system( 'ls' )
weekly20051204.doc
weekly20051211.doc
0
>>>

最後顯示出來的 0 不是 ls 程式的輸出,而是其傳回值。

os.system() 函式能進行最簡單的外部程式呼叫,不能對該程式的輸出入資料進一步處理;如果我們只想簡單執行程式,os.system() 函式將是最佳的選擇。

5.1   管線

當我們也需要對外部程式的輸出入資料進行處理的時候,os.system() 就不夠用了。Python 另外有 popen2 模組,可以讓我們管理外部程式子行程的輸出入管線 (pipe)。在 popen2 模組裡有 popen2(), popen3()popen4() 三個工具函式,分別會重導向子行程的標準輸出入、標準輸出入及錯誤輸出、標準輸出合併錯誤輸出及標準輸入。

簡單用範例來說明最常用的 popen2() (別忘了先 import popen2 喔):

>>> stdout, stdin = popen2.popen2("ls")
>>> ostr = stdout.read()
>>> print ostr
weekly20051204.doc
weekly20051211.doc

>>>

popen2.popen2() 會傳回連結到 ls 程式輸出入的兩個檔案物件,我們取名為 stdoutstdin。呼叫了 popen2.popen2() 之後,外部程式馬上就會執行,然後我們就能從 stdout 檔案物件裡讀出該外部程式的標準輸出資料了。如此一來,該程式的執行結果就不會直接顯示在終端機上,我們可以在 Python 裡面先處理過以後,再決定該怎麼辦。

如果我們想呼叫的程式也會進行錯誤輸出 (stderr),而我們想要處理的話,就改用 popen3()popen4() 函式。popen3() 的錯誤輸出會連接至一個獨立的檔案物件,而 popen4() 則會把錯誤輸出一起放到標準輸出所連結的檔案物件裡;你可以視需要使用。

註記

在 Python 2.4 裡有一個新的 subprocess 模組,可以執行所有的外部程式呼叫功能。所以在 Python 2.4 裡不再需要 ospopen2 模組裡的相關函式了;當然,舊模組不會消失,所以在 Python 2.4 裡還是可以用 popen2,我們的舊程式不會出問題。

6   網際網路通訊

Python 內建的程式庫裡就具備相當方便的網際網路通訊功能,不必呼叫外部程式。

網際網路通訊是個大範圍,其中最常用到的大概數全球資訊網了;我們舉 Zope 應用程式伺服器來作例子。Zope 使用 ZODB 物件資料庫來儲存資料,這個系統會把存取動作紀錄下來,當使用者刪除其中的資料時,資料不會實際刪除,要等到手動壓縮 (pack) 資料庫的時候,才會真正把資料刪除。這個壓縮功能的動作選項是放在 web-based 的 ZMI 裡面,沒有指令行介面;如果我們不想手動連進 ZMI 來執行壓縮,就得寫一個能進行 HTTP 操作的指令稿。

我們要寫的程式應該具有以下的命令列介面:

packzope.py -u<URL of Zope server> -d<day> -U<username> -P<password>

這個 packzope.py 程式要負責用 HTTP 和伺服器溝通,把從命令列取得的使用者名稱和密碼提供給 Zope 伺服器,並且用 GET 方法把要壓縮的天數 (捨棄指定天數前的資料) 告訴 Zope 伺服器。以下是寫好的程式:

#!/usr/bin/env python

import sys
import urllib

class parameters:
  def __init__(self):
    from optparse import OptionParser, OptionGroup
    op = OptionParser(
        usage = "usage: %prog -u URL -d DAYS -U USERNAME -P PASSWORD",
        version = "%prog, " + "%s" % __revision__
        )
    op.add_option("-u", action="store", type="string", \
        dest="url", \
        help="URL of site to open"
        )
    op.add_option("-d", action="store", type="int", \
        dest="days", default=1, \
        help="erase days before"
        )
    op.add_option("-U", action="store", type="string", \
        dest="username", \
        help="username"
        )
    op.add_option("-P", action="store", type="string", \
        dest="password", \
        help="password"
        )
    self.op = op
    (self.options, self.args) = self.op.parse_args()
params = parameters()

if not params.options.url or \
   not params.options.username or \
   not params.options.password :
  params.op.print_help()
  sys.exit(1)

url = "%s/Control_Panel/Database/manage_pack?days:float=%d" % \
     (params.options.url, params.options.days)

class MyOpener(urllib.FancyURLopener):
  def get_user_passwd(self, host, realm, clear_cache = 0):
    return params.options.username, params.options.password

def main():
  try:
    f = MyOpener().open(url).read()
    print "Successfully packed ZODB on host %s" % params.options.url
  except:
    print "Cannot open URL %s, aborted" % url
    raise

if __name__ == '__main__':
  main()

程式前半段在處理命令行參數 (class parameters),而在 main() 函式裡實際進行連線動作。packzope.py 利用 urllib 模組來連結 Zope 伺服器,並利用 subclassing urllib.FancyURLopener 類別來自訂使用者名稱與密碼的輸入。壓縮完畢之後,程式會輸出以下的字樣:

Successfully packed ZODB on host http://someplace:port

我們可以把 packzope.py 放到 crontab 裡定期執行。這就是一種自動化網路操作。

7   結語

本文藉由討論以 Python 進行 Linux 操作自動化的技巧,對 Python 的應用作了進一步的介紹。當然,在進行任何種類的 Python 程式開發時,都可以參考 Python 的線上說明文件。Dive into Python 是一本容易上手的自由 Python 書籍,你也可以在網路上找到中文譯本。

在下一期的內容裡,我們要介紹如何用 Pygtk 來撰寫簡單的 GUI 程式。