當你想實現一個命令列程式時,或許第一個想到的是用 Python 來實現。比如 CentOS 上大名鼎鼎的包管理工具 yum 就是基於 Python 實現的。
而 Python 的世界中有很多命令列庫,每個庫都各具特色。但我們往往不知道其背後的設計理念,也因此在選擇時感到迷茫。這些庫的作者為何在重複造輪子,他是從哪個角度來考慮,來讓命令列庫“演變”到一個新的更好用的形態。
為了能夠更加直觀地感受到命令列庫的設計理念,在此之前,我們不妨設計一個名為 calc 的命令列程式,它能:
支援 echo 子命令,對輸入的字串做處理來輸出若不提供任何選項,則輸出原始內容若提供 --lower 選項,則輸出小寫字串若提供 --upper 選項,則輸出大寫字串支援 eval 子命令,針對輸入呼叫 Python 的 eval 函式,將結果輸出(作為示例,我們不考慮安全性問題)argparseargparse 作為 Python 的標準庫,可能會是你想到第一個命令列庫。
argparse 的設計理念就是提供給開發者最細粒度的控制。換句話說,你需要告訴它必不可少的細節,比如引數的型別是什麼,處理引數的動作是怎樣的。
在 argparse 的世界中,需要:
設定解析器,作為後續定義引數和解析命令列的基礎。如果要實現子命令,則還要設定子解析器。定義引數,包括名稱、型別、動作、幫助等。其中的動作是指對於此引數的初步處理,是直接存下來,還是作為布林值,亦或是追加到列表中等等解析引數根據引數編寫業務邏輯以下示例是基於 argparse 的 calc 程式:
import argparsedef echo_text(args): if args.lower: print(args.text.lower()) elif args.upper: print(args.text.upper()) else: print(args.text)def eval_expression(args): print(eval(args.expression))# 1. 設定解析器parser = argparse.ArgumentParser(description='Calculator Program.')subparsers = parser.add_subparsers()# 2. 定義引數# 2.1 echo 子命令# echo 子解析器echo_parser = subparsers.add_parser( 'echo', help='Echo input text in multiple forms')# 新增位置引數 textecho_parser.add_argument('text', help='Input text')# --lower/--upper 互斥,需要設定互斥組echo_group = echo_parser.add_mutually_exclusive_group()# 新增選項引數 --lower/--upper,這裡action的作用就是將之變為布林變數echo_parser.add_argument('--lower', action='store_true', help='Lower input text')echo_parser.add_argument('--upper', action='store_true', help='Upper input text')# 設定此命令的處理函式echo_parser.set_defaults(handle=echo_text)# eval 子解析器eval_parser = subparsers.add_parser( 'eval', help='Eval input expression and return result')# 新增位置引數 expressioneval_parser.add_argument('expression', help='Expression to eval')# 設定此命令的處理函式eval_parser.set_defaults(handle=eval_expression)# 3. 解析引數args = parser.parse_args(['echo', '--upper', 'Hello, World'])print(args) # 結果:Namespace(lower=True, text='Hello, World', upper=False)# args = parser.parse_args(['eval', '1+2*3'])# print(args) # 結果:Namespace(expression='1+2*3')# 4. 業務邏輯處理args.handle(args)
從上述示例可以看到,要實現子命令,對應地需要新增子解析器。然後最為關鍵的就是要定義引數,需要透過 add_argument 很明確地告訴 argparse 引數長什麼樣,需要怎麼處理:
它是位置引數 text/expression,還是選項引數 --lower/--upper若是選項引數,是否互斥引數的是存成什麼形式,比如 action='store_true' 表示存成布林子命令的響應函式透過 argparse 實現的整個過程是很計算機思維的,且比較冗長。其優點是靈活,所有的功能都涵蓋到了;但缺點則是將定義和處理割裂,尤其在程式功能複雜時會愈加凌亂和不直觀,難以理解和維護。
docopt有人喜歡 argparse 這樣命令式的寫法,就會有人喜歡宣告式的寫法。而 docopt 恰巧這就是這樣一個命令列庫。設計它的初衷就是對於熟悉命令列程式幫助資訊的開發者來說,直接透過編寫幫助資訊來描述整個命令列引數定義的元資訊會是更加簡單快捷的方式。這種宣告式的語法描述某種程度上會比過程式地定義引數來的更加簡單和直觀。
在 docopt 的世界中,需要:
定義介面描述/幫助資訊,這一步是它的特色和重點解析引數,獲得一個字典根據引數編寫業務邏輯以下示例是基於 docopt 的 calc 程式:
# 1. 定義介面描述/幫助資訊"""Calculator Program.Usage: calc echo [--lower | --upper] <text> calc eval <expression>Commands: echo Echo input text in multiple forms eval Eval input expression and return resultOptions: -h --help Show help --lower Lower input text --upper Upper input text"""from docopt import docoptdef echo_text(args): if args['--lower']: print(args['<text>'].lower()) elif args['--upper']: print(args['<text>'].upper()) else: print(args['<text>'])def eval_expression(args): print(eval(args['<expression>']))# 2. 解析命令列args = docopt(__doc__, argv=['echo', '--upper', 'Hello, World'])# 結果:{'--lower': False, '--upper': True, '<expression>': None, '<text>': 'Hello, World', 'echo': True, 'eval': False}print(args)# 3. 業務邏輯if args['echo']: echo_text(args)elif args['eval']: eval_expression(args)
從上述示例可以看到,我們透過文件字串 __doc__ 定義了介面描述,這和 argparse 中 一系列引數定義的行為是等價的,然後 docopt 便會根據這個元資訊把命令列引數轉換為一個字典。業務邏輯中就需要對這個字典進行處理。
相比於 argparse:
對於較為複雜的命令,命令和引數元資訊的定義上 docopt 會更加簡單在業務邏輯的處理上,argparse 在一些簡單引數的處理上會更加便捷,且命令和處理函式之間可以方便路由(比如示例中的情形);相對來說 docopt 轉換為字典後就把所有處理交給業務邏輯的方式會更加複雜click不論是 argparse 還是 docopt,元資訊的定義和處理都是割裂開的。而命令列程式本質上是定義引數並對引數進行處理,而處理引數的邏輯一定是與所定義的引數有關聯的。那可不可以用函式和裝飾器來實現處理引數邏輯與定義引數的關聯呢?click 正好就是以這種使用方式來設計的。
裝飾器這樣一個優雅的語法糖是元資訊定義和處理邏輯之間的絕妙膠水,從而暗示了兩者的路有關係。對比於前兩個命令列庫的路由實現著實優雅了不少。
在 click 的世界中:
透過裝飾器定義命令和引數的元資訊使用此裝飾器裝飾處理函式對,就是這麼簡單。
以下示例是基於 click 的 calc 程式:
import sysimport clicksys.argv = ['calc', 'echo', '--upper', 'Hello, World']@click.group(help='Calculator Program.')def cli(): pass# 2. 定義引數@cli.command(name='echo', help='Echo input text in multiple forms')@click.argument('text')@click.option('--lower', is_flag=True, help='Lower input text')@click.option('--upper', is_flag=True, help='Upper input text')# 1. 業務邏輯def echo_text(text, lower, upper): if lower: print(text.lower()) elif upper: print(text.upper()) else: print(text)@cli.command(name='eval', help='Eval input expression and return result')@click.argument('expression')def eval_expression(expression): print(eval(expression))cli()
從上述示例可以看到,元資訊定義和處理邏輯無縫繫結在一起,能夠直觀地看出對應的引數會如何處理,這個優勢在有大量引數需要處理時顯得尤為突出。在處理函式中,接收到不再是像 argparse 或 docopt 中的一個包含所有引數的變數,而是具體的引數變數,這讓處理邏輯在引數使用上也變得更加簡便。
此外,click 還內建了很多實用工具和增強能力,如引數自動補全、分頁支援、顏色、進度條等功能,能夠有效提升開發效率。
fire雖然前面三個庫已經足夠強大,但是仍然會有人認為不夠簡單。是否還有進一步簡化的空間呢?如果只是定義函式,是否能讓框架推測出引數元資訊呢?理論上還真是可以。
fire 用一種面向廣義物件的方式來玩轉命令列,這種物件可以是類、函式、字典、列表等,它更加靈活,也更加簡單。你都不需要定義引數型別,fire 會根據輸入和引數預設值來自動判斷,這無疑進一步簡化了實現過程。
在 fire 的世界中,定義 Python 物件就夠了。
以下示例是基於 fire 的 calc 程式:
import sysimport firesys.argv = ['calc', 'echo', '"Hello, World"', '--upper']# 業務邏輯# 類中有幾個方法,就意味著命令列程式有幾個同名命令class Calc: # text 沒有任何預設值,視為位置引數 # lower/upper 有布林型別的預設值,視為選項引數 --lower/--upper, # 且指定了為 True,不指定 False def echo(self, text, lower=False, upper=False): """Echo input text in multiple forms""" if lower: print(text.lower()) elif upper: print(text.upper()) else: print(text) def eval(self, expression): """Eval input expression and return result""" print(eval(expression))fire.Fire(Calc)
從上面的示例可以看出,使用 fire 足夠的簡單,一切都是根據約定來進行推斷,包括支援哪些命令,每個命令接受的什麼引數和選項。這種方式可以說是足夠的 Pythonic,相比於 click,fire 把命令列引數的定義和函式引數的定義融為了一體。透過它,我們真的就只用關注業務邏輯。
不過簡單往往也意味著對於複雜需求的捉襟見肘。僅僅透過預設值來推導命令列引數所能表達的情況是有限的,比如互斥選項、位置引數的型別限定都無法透過框架來表達,而只能由業務邏輯去判斷。
typer那麼該如何在保持像 fire 這樣簡單實現的方式下,增強引數元資訊的表達能力呢?既然預設引數的能力有限,那麼如果使用 Python 3 的型別註解呢?
typer 站在 click 巨人的肩膀上,藉助 Python 3 型別註解的特性,既滿足了簡單直觀編寫的需要,又達到了應對複雜場景的目的,可謂是現代化的命令列庫。
在 typer 的世界中,也是直接編寫業務邏輯,和 fire 稍稍不同的點是使用了型別註解和預設值來表達引數元資訊定義。
以下示例是基於 typer 的 calc 程式:
import sysimport typersys.argv = ['calc', 'echo', '"Hello, World"', '--upper']cli = typer.Typer(help='Calculator Program.')# 定義命令 echo,及處理函式# text 無預設值,視為位置引數,型別為字串# lower/upper 型別為 bool,預設值為 False,視為選項 --lower/--upper,# 且指定了為 True,不指定 [email protected](name='echo')def echo_text(text: str, lower: bool = False, upper: bool = False): """Echo input text in multiple forms""" if lower: print(text.lower()) elif upper: print(text.upper()) else: print(text)# 定義命令 eval,及處理函式# expression 無預設值,視為位置引數,型別為字串@cli.command(name='eval')def eval_expression(expression: str): """Eval input expression and return result""" print(eval(expression))cli()
從上面的示例可以看出,相比於 click,它免去了引數元資訊的繁瑣定義,取而代之的是型別註解;相比於 fire,它的元資訊定義能力則大大增強,可以透過指定預設值為 typer.Option 或 typer.Argument 來進一步擴充套件引數和選項的語義。可以說是,typer 達到了簡單與靈活的完美平衡。
橫向對比最後,我們橫向對比下 argparse、docopt、click、fire、typer 庫的各項功能和特點:
Python 的命令列庫種類繁多、各具特色,它們並非是重複造輪子的產物,其背後的思想值得學習。結合橫向對比的總結,可以選擇出符合使用場景的庫。如果幾個庫都符合,那麼就選擇你所偏愛的風格。