用 Plumbum 開發 Python 命令列工具

Tomer Filiba發表於2018-05-04

摘要:本文翻譯自 Python Plumbum 開源庫的官方文件 Plumbum CLI 部分,主要介紹如何使用 Plumbum CLI 工具包來開發 Python 命令列應用程式,這是一個非常 Pythonic、容易使用、功能強大的工具包,非常值得廣大 Python 程式設計師掌握並使用。

用 Plumbum 開發 Python 命令列工具

輕鬆執行程式的另一方面是輕鬆編寫 CLI 程式。Python 指令碼一般使用 optparse 或者最新的 argparse 及其衍生品來開發命令列工具,但是所有這些表現力有限,而且非常不直觀(甚至不夠 Pythonic)。Plumbum 的 CLI 工具包提供了一個程式化的方法來構建命令列應用程式,不需要建立一個解析器物件,然後填充一系列“選項”,該 CLI 工具包使用內省機制將這些原語轉義成 Pythonic 結構。

總體來看,Plumbum CLI 應用程式是一個繼承自 plumbum.cli.Application 的類。這些類定義了一個 main() 方法,並且可選地公開出方法和屬性來作為命令列的選項。這些選項可能需要引數,而任何剩餘的位置引數會根據 main 函式的宣告來將其賦予 main 方法。一個簡單的 CLI 應用程式看起來像如下這樣:

from plumbum import cli

class MyApp(cli.Application):
    verbose = cli.Flag(["v", "verbose"], help = "If given, I will be very talkative")

    def main(self, filename):
        print("I will now read {0}".format(filename))
        if self.verbose:
            print("Yadda " * 200)

if __name__ == "__main__":
    MyApp.run()

你可以執行該程式:

$ python example.py foo
I will now read foo

$ python example.py --help
example.py v1.0

Usage: example.py [SWITCHES] filename
Meta-switches:
    -h, --help                 Prints this help message and quits
    --version                  Prints the program's version and quits

Switches:
    -v, --verbose              If given, I will be very talkative

到現在為止,你只看到了非常基本的使用。我們現在開始探索該庫。

新版本 1.6.1: 你可以直接執行應用程式 MyApp(),不需要引數,也不需要呼叫 .main()

應用程式

Application 類是你的應用程式的“容器”,該“容器”由一個你需要實現的main()方法和任何數量公開選項函式和屬性。你的應用程式的入口是類方法 run,該方法例項化你的類、解析引數、呼叫所有的選項函式,然後使用給的位置引數來呼叫main()函式。為了從命令列執行你的應用程式,你所要做的是:

if __name__ == "__main__":
    MyApp.run()

除了 run()main()Application 類還公開了兩個內建的選項函式:help()version(),分別用於顯示幫助和程式的版本。預設情況下,--hep-h 會呼叫 help()--version-v 會呼叫 version(),這些函式被呼叫後會顯示相應的資訊然後退出(沒有處理任何其他選項)。

你可以透過定義類屬性來自定義 help()version() 顯示的資訊,比如 PROGNAMEVERSIONDESCRIPTION。舉例:

class MyApp(cli.Application):
    PROGNAME = "Foobar"
    VERSION = "7.3"

顏色

新版本 1.6

該庫也支援終端字元顏色控制。你可以直接將 PROGNAME, VERSIONDESCRIPTION 變為帶顏色的字串。如果你給 PROGNAME 設定了顏色,你會得到自定義的程式名字和顏色。使用方法字串的顏色可以透過設定 COLOR_USAGE 來生效,不同選項組的顏色可以透過設定 COLOR_GROUPS 字典來生效。

舉例如下:

class MyApp(cli.Application):
    PROGNAME = colors.green
    VERSION = colors.blue | "1.0.2"
    COLOR_GROUPS = {"Meta-switches" : colors.bold & colors.yellow}
    opts =  cli.Flag("--ops", help=colors.magenta | "This is help")
SimpleColorCLI.py 1.0.2

Usage:
    SimpleColorCLI.py [SWITCHES]

Meta-switches
    -h, --help         Prints this help message and quits
    --help-all         Print help messages of all subcommands and quit
    -v, --version      Prints the program's version and quits

Switches
    --ops              This is help

選項函式

switch 裝飾器是該 CLI 開發工具包的“靈魂”,它會公開你的 CLI 應用程式的方法來作為 CLI 命令列選項,這些方法執行透過命令列來呼叫。我們測試下如下應用:

class MyApp(cli.Application):
    _allow_root = False       # provide a default

    @cli.switch("--log-to-file", str)
    def log_to_file(self, filename):
        """Sets the file into which logs will be emitted"""
        logger.addHandler(FileHandle(filename))

    @cli.switch(["-r", "--root"])
    def allow_as_root(self):
        """If given, allow running as root"""
        self._allow_root = True

    def main(self):
        if os.geteuid() == 0 and not self._allow_root:
            raise ValueError("cannot run as root")

當程式執行時,選項函式透過相應的引數被呼叫。比如,$ ./myapp.py --log-to-file=/tmp/log 將被轉化成呼叫 app.log_to_file("/tmp/log")。在選項函式被執行後,程式的控制權會被傳遞到 main 方法。

注意

方法的文件字串和引數名字會被用來渲染幫助資訊,儘量保持你的程式碼 DRY

autoswitch 可以從函式名字中推斷出選項的名稱,舉例如下:

@cli.autoswitch(str)
def log_to_file(self, filename):
    pass

這會將選項函式和 --log-to-file 繫結。

選項引數

如上面例子所示,選項函式可能沒有引數(不包括 self)或者有一個引數。如果選項函式接受一個引數,必須指明該引數的型別。如果你不需要特殊的驗證,只需傳遞 str,否則,您可能會傳遞任何型別(或實際上可呼叫的任何型別),該型別將接收一個字串並將其轉換為有意義的物件。如果轉換是不可行的,那麼會丟擲 TypeError 或者 ValueError 異常。

舉例:

class MyApp(cli.Application):
    _port = 8080

    @cli.switch(["-p"], int)
    def server_port(self, port):
        self._port = port

    def main(self):
        print(self._port)
$ ./example.py -p 17
17
$ ./example.py -p foo
Argument of -p expected to be <type 'int'>, not 'foo':
    ValueError("invalid literal for int() with base 10: 'foo'",)

工具包包含兩個額外的“型別”(或者是是驗證器):RangeSetRange 指定一個最小值和最大值,限定一個整數在該範圍內(閉區間)。Set 指定一組允許的值,並且期望引數匹配這些值中的一個。示例如下:

class MyApp(cli.Application):
    _port = 8080
    _mode = "TCP"

    @cli.switch("-p", cli.Range(1024,65535))
    def server_port(self, port):
        self._port = port

    @cli.switch("-m", cli.Set("TCP", "UDP", case_sensitive = False))
    def server_mode(self, mode):
        self._mode = mode

    def main(self):
        print(self._port, self._mode)
$ ./example.py -p 17
Argument of -p expected to be [1024..65535], not '17':
    ValueError('Not in range [1024..65535]',)
$ ./example.py -m foo
Argument of -m expected to be Set('udp', 'tcp'), not 'foo':
    ValueError("Expected one of ['UDP', 'TCP']",)

注意 工具包中還有其他有用的驗證器:ExistingFile(確保給定的引數是一個存在的檔案),ExistingDirectory(確保給定的引數是一個存在的目錄),NonexistentPath(確保給定的引數是一個不存在的路徑)。所有這些將引數轉換為本地路徑

可重複的選項

很多時候,你需要在同一個命令列中多次指定某個選項。比如,在 gcc 中,你可能使用 -I 引數來引入多個目錄。預設情況下,選項只能指定一次,除非你給 switch 裝飾器傳遞 list = True 引數。

class MyApp(cli.Application):
    _dirs = []

    @cli.switch("-I", str, list = True)
    def include_dirs(self, dirs):
        self._dirs = dirs

    def main(self):
        print(self._dirs)
$ ./example.py -I/foo/bar -I/usr/include
['/foo/bar', '/usr/include']

注意 選項函式只被呼叫一次,它的引數將會變成一個列表。

強制的選項

如果某個選項是必須的,你可以給 switch 裝飾器傳遞 mandatory = True 來實現。這樣的話,如果使用者不指定該選項,那麼程式是無法執行的。

選項依賴

很多時候,一個選項的出現依賴另一個選項,比如,如果不給定 -y 選項,那麼 -x 選項是無法給定的。這種限制可以透過給 switch 裝飾器傳遞 requires 引數來實現,該引數是一個當前選項所依賴的選項名稱列表。如果不指定某個選項所依賴的其他選項,那麼使用者是無法執行程式的。

class MyApp(cli.Application):
    @cli.switch("--log-to-file", str)
    def log_to_file(self, filename):
        logger.addHandler(logging.FileHandler(filename))

    @cli.switch("--verbose", requires = ["--log-to-file"])
    def verbose(self):
        logger.setLevel(logging.DEBUG)
$ ./example --verbose
Given --verbose, the following are missing ['log-to-file']

警告 選項函式的呼叫順序和命令列指定的選項的順序是一致的。目前不支援在程式執行時計算選項函式呼叫的拓撲順序,但是將來會改進。

選項互斥

有些選項依賴其他選項,但是有些選項是和其他選項互斥的。比如,--verbose--terse 同時存在是不合理的。為此,你可以給 switch 裝飾器指定 excludes 列表來實現。

class MyApp(cli.Application):
    @cli.switch("--log-to-file", str)
    def log_to_file(self, filename):
        logger.addHandler(logging.FileHandler(filename))

    @cli.switch("--verbose", requires = ["--log-to-file"], excludes = ["--terse"])
    def verbose(self):
        logger.setLevel(logging.DEBUG)

    @cli.switch("--terse", requires = ["--log-to-file"], excludes = ["--verbose"])
    def terse(self):
        logger.setLevel(logging.WARNING)
$ ./example --log-to-file=log.txt --verbose --terse
Given --verbose, the following are invalid ['--terse']

選項分組

如果你希望在幫助資訊中將某些選項組合在一起,你可以給 switch 裝飾器指定 group = "Group Name", Group Name 可以是任意字串。當顯示幫助資訊的時候,所有屬於同一個組的選項會被聚合在一起。注意,分組不影響選項的處理,但是可以增強幫助資訊的可讀性。

選項屬性

很多時候只需要將選項的引數儲存到類的屬性中,或者當某個屬性給定後設定一個標誌。為此,工具包提供了 SwitchAttr,這是一個資料描述符,用來儲存引數。 該工具包還提供了兩個額外的 SwitchAttr:Flag(如果選項給定後,會給其賦予預設值)和 CountOf (某個選項出現的次數)。

class MyApp(cli.Application):
    log_file = cli.SwitchAttr("--log-file", str, default = None)
    enable_logging = cli.Flag("--no-log", default = True)
    verbosity_level = cli.CountOf("-v")

    def main(self):
        print(self.log_file, self.enable_logging, self.verbosity_level)
$ ./example.py -v --log-file=log.txt -v --no-log -vvv
log.txt False 5

環境變數

新版本 1.6

你可以使用 envname 引數將環境變數作為 SwitchAttr 的輸入。舉例如下:

class MyApp(cli.Application):
    log_file = cli.SwitchAttr("--log-file", str, envname="MY_LOG_FILE")

    def main(self):
        print(self.log_file)
$ MY_LOG_FILE=this.log ./example.py
this.log

在命令列給定變數值會覆蓋相同環境變數的值。

Main

一旦當所有命令列引數被處理後 ,main() 方法會獲取程式的控制,並且可以有任意數量的位置引數,比如,在 cp -r /foo /bar 中, /foo/bar 是位置引數。程式接受位置引數的數量依賴於 main() 函式的宣告:如果 main 方法有 5 個引數,2 個是有預設值的,那麼使用者最少需要提供 3 個位置引數並且總數量不能多於 5 個。如果 main 方法的宣告中使用的是可變引數(*args),那麼位置引數的個數是沒有限制的。

class MyApp(cli.Application):
    def main(self, src, dst, mode = "normal"):
        print(src, dst, mode)
$ ./example.py /foo /bar
/foo /bar normal
$ ./example.py /foo /bar spam
/foo /bar spam
$ ./example.py /foo
Expected at least 2 positional arguments, got ['/foo']
$ ./example.py /foo /bar spam bacon
Expected at most 3 positional arguments, got ['/foo', '/bar', 'spam', 'bacon']

注意 該方法的宣告也用於生成幫助資訊,例如:

Usage:  [SWITCHES] src dst [mode='normal']

使用可變引數:

class MyApp(cli.Application):
    def main(self, src, dst, *eggs):
        print(src, dst, eggs)
$ ./example.py a b c d
a b ('c', 'd')
$ ./example.py --help
Usage:  [SWITCHES] src dst eggs...
Meta-switches:
    -h, --help                 Prints this help message and quits
    -v, --version              Prints the program's version and quits

位置驗證

新版本 1.6

你可以使用 cli.positional 裝飾器提供的驗證器來驗證位置引數。只需在裝飾器中傳遞與 main 函式中的相匹配的驗證器即可。例如:

class MyApp(cli.Application):
    @cli.positional(cli.ExistingFile, cli.NonexistentPath)
    def main(self, infile, *outfiles):
        "infile is a path, outfiles are a list of paths, proper errors are given"

如果你的程式只在 Python 3 中執行,你可以使用註解來指定驗證器,例如:

class MyApp(cli.Application):
    def main(self, infile : cli.ExistingFile, *outfiles : cli.NonexistentPath):
    "Identical to above MyApp"

如果 positional 裝飾器存在,那麼註解會被忽略。

子命令

新版本 1.1

隨著 CLI 應用程式的擴充套件,功能變的越來越多,一個通常的做法是將其邏輯分成多個子應用(或者子命令)。一個典型的例子是版本控制系統,比如 gitgit 是根命令,在這之下的子命令比如 commit 或者 push 是巢狀的。git 甚至支援命令別名,這執行使用者自己建立一些子命令。Plumbum 寫類似這樣的程式是很輕鬆的。

在我們開始瞭解程式碼之前,先強調兩件事情:

  • 在 Plumbum 中,每個子命令都是一個完整的 cli.Application 應用,你可以單獨執行它,或者從所謂的根命令中分離出來。當應用程式單獨執行是,它的父屬性是 None,當以子命令執行時,它的父屬性指向父應用程式。同樣,當父應用使用子命令執行時,它的內嵌命令被設定成內嵌應用。
  • 每個子命令只負責它自己的選項引數(直到下一個子命令)。這允許應用在內嵌應用呼叫之前來處理它自己的選項和位置引數。例如 git --foo=bar spam push origin --tags:根應用 git 負責選項 --foo 和位置選項 spam ,內嵌應用 push 負責在它之後的引數。從理論上講,你可以將多個子應用程式巢狀到另一個應用程式中,但在實踐中,通常巢狀層級只有一層。

這是一個模仿版本控制系統的例子 geet。我們有一個根應用 Geet ,它有兩個子命令 GeetCommitGeetPush:這兩個子命令透過 subcommand 裝飾器來將其附加到根應用。

class Geet(cli.Application):
    """The l33t version control"""
    VERSION = "1.7.2"

    def main(self, *args):
        if args:
            print("Unknown command {0!r}".format(args[0]))
            return 1   # error exit code
        if not self.nested_command:           # will be ``None`` if no sub-command follows
            print("No command given")
            return 1   # error exit code

@Geet.subcommand("commit")                    # attach 'geet commit'
class GeetCommit(cli.Application):
    """creates a new commit in the current branch"""

    auto_add = cli.Flag("-a", help = "automatically add changed files")
    message = cli.SwitchAttr("-m", str, mandatory = True, help = "sets the commit message")

    def main(self):
        print("doing the commit...")

@Geet.subcommand("push")                      # attach 'geet push'
class GeetPush(cli.Application):
    """pushes the current local branch to the remote one"""
    def main(self, remote, branch = None):
        print("doing the push...")

if __name__ == "__main__":
    Geet.run()

注意

  • 由於 GeetCommit 也是一個 cli.Application,因此你可以直接呼叫 GeetCommit.run() (這在應用的上下文是合理的)
  • 你也可以不用裝飾器而使用 subcommand 方法來附加子命令:Geet.subcommand("push", GeetPush)

以下是執行該應用程式的示例:

$ python geet.py --help
geet v1.7.2
The l33t version control

Usage: geet.py [SWITCHES] [SUBCOMMAND [SWITCHES]] args...
Meta-switches:
    -h, --help                 Prints this help message and quits
    -v, --version              Prints the program's version and quits

Subcommands:
    commit                     creates a new commit in the current branch; see
                               'geet commit --help' for more info
    push                       pushes the current local branch to the remote
                               one; see 'geet push --help' for more info

$ python geet.py commit --help
geet commit v1.7.2
creates a new commit in the current branch

Usage: geet commit [SWITCHES]
Meta-switches:
    -h, --help                 Prints this help message and quits
    -v, --version              Prints the program's version and quits

Switches:
    -a                         automatically add changed files
    -m VALUE:str               sets the commit message; required

$ python geet.py commit -m "foo"
doing the commit...

配置解析器

應用程式的另一個常見的功能是配置檔案解析器,解析後臺 INI 配置檔案:Config (或者 ConfigINI)。使用示例:

from plumbum import cli

with cli.Config('~/.myapp_rc') as conf:
    one = conf.get('one', '1')
    two = conf.get('two', '2')

如果配置檔案不存在,那麼將會以當前的 key 和預設的 value 來建立一個配置檔案,在呼叫 .get 方法時會得到預設值,當上下文管理器存在時,檔案會被建立。如果配置檔案存在,那麼該檔案將會被讀取並且沒有任何改變。你也可以使用 [] 語法來強制設定一個值或者當變數不存在時獲取到一個 ValueError。如果你想避免上下文管理器,你也可以使用 .read.write

ini 解析器預設使用 [DEFAULT] 段,就像 Python 的 ConfigParser。如果你想使用一個不同的段,只需要在 key 中透過 . 將段和標題分隔開。比如 conf['section.item'] 會將 item 放置在 [section] 下。所有儲存在 ConfigINI 中的條目會被轉化成 strstr 是經常返回的。

終端實用程式

plumbum.cli.terminal 中有多個終端實用程式,用來幫助製作終端應用程式。

get_terminal_size(default=(80,25)) 允許跨平臺訪問終端螢幕大小,返回值是一個元組 (width, height)。還有幾個方法可以用來詢問使用者輸入,比如 readline, ask, chooseprompt 都是可用的。

Progress(iterator) 可以使你快速地從迭代器來建立一個進度條。簡單地打包一個 slow 迭代器並迭代就會生成一個不錯的基於使用者螢幕寬度的文字進度條,同時會顯示剩餘時間。如果你想給 fast 迭代器建立一個進度條,並且在迴圈中包含程式碼,那麼請使用 Progress.wrap 或者 Progress.range。例如:

for i in Progress.range(10):
    time.sleep(1)

如果在終端中有其他輸出,但是仍然需要一個進度條,請傳遞 has_output=True 引數來禁止進度條清除掉歷史輸出。

plumbum.cli.image 中提供了一個命令列繪圖器(Image)。它可以繪製一個類似 PIL 的影像:

Image().show_pil(im)

Image 建構函式接受一個可選的 size 引數(如果是 None,那麼預設是當前終端大小)和一個字元比例,該比例來自當前字元的高度和寬度的度量,預設值是 2.45。如果設定為 None,ratio 將會被忽略,影像不再被限制成比例縮放。要直接繪製一個影像,show 需要一個檔名和一對引數。show_pilshow_pil_double 方法直接接受一個 PIL-like 物件。為了從命令列繪製影像,該模組可以直接被執行:python -m plumbum.cli.image myimage.png

要獲取幫助列表和更多的資訊請參見 api docs

請參閱

  • filecopy.py 示例
  • geet.py - 一個可執行的使用子命令的示例
  • RPyC 已經將基於 bash 的編譯指令碼換成了 Plumbum CLI。這是多麼簡短和具有可讀性
  • 一篇部落格,講述 CLI 模組的理論

相關文章