當你想實作一個指令行程式時,或許第一個想到的是用 Python 來實作。比如 CentOS 上大名鼎鼎的包管理工具 yum
就是基于 Python 實作的。
而 Python 的世界中有很多指令行庫,每個庫都各具特色。但我們往往不知道其背後的設計理念,也是以在選擇時感到迷茫。這些庫的作者為何在重複造輪子,他是從哪個角度來考慮,來讓指令行庫“演變”到一個新的更好用的形态。
為了能夠更加直覺地感受到指令行庫的設計理念,在此之前,我們不妨設計一個名為
calc
的指令行程式,它能: - 支援
子指令,對輸入的字元串做處理來輸出echo
- 若不提供任何選項,則輸出原始内容
- 若提供
選項,則輸出小寫字元串--lower
-
選項,則輸出大寫字元串--upper
-
子指令,針對輸入調用 Python 的eval
函數,将結果輸出(作為示例,我們不考慮安全性問題)eval
argparse
作為 Python 的标準庫,可能會是你想到第一個指令行庫。
argparse
的設計理念就是提供給開發者最細粒度的控制。換句話說,你需要告訴它必不可少的細節,比如參數的類型是什麼,處理參數的動作是怎樣的。
在
argparse
的世界中,需要:
- 設定解析器,作為後續定義參數和解析指令行的基礎。如果要實作子指令,則還要設定子解析器。
- 定義參數,包括名稱、類型、動作、幫助等。其中的動作是指對于此參數的初步處理,是直接存下來,還是作為布爾值,亦或是追加到清單中等等
- 解析參數
- 根據參數編寫業務邏輯
以下示例是基于
argparse
的
calc
程式:
import argparse
def 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')
# 添加位置參數 text
echo_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')
# 添加位置參數 expression
eval_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
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 result
Options:
-h --help Show help
--lower Lower input text
--upper Upper input text
"""
from docopt import docopt
def 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
calc
import sys
import click
sys.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
的世界中,定義 Python 對象就夠了。
fire
calc
import sys
import fire
sys.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 的類型注解呢?
站在
click
巨人的肩膀上,借助 Python 3 類型注解的特性,既滿足了簡單直覺編寫的需要,又達到了應對複雜場景的目的,可謂是現代化的指令行庫。
typer
的世界中,也是直接編寫業務邏輯,和
fire
稍稍不同的點是使用了類型注解和預設值來表達參數元資訊定義。
typer
calc
import sys
import typer
sys.argv = ['calc', 'echo', '"Hello, World"', '--upper']
cli = typer.Typer(help='Calculator Program.')
# 定義指令 echo,及處理函數
# text 無預設值,視為位置參數,類型為字元串
# lower/upper 類型為 bool,預設值為 False,視為選項 --lower/--upper,
# 且指定了為 True,不指定 False
@cli.command(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
庫的各項功能和特點:
argpase | |||||
---|---|---|---|---|---|
使用步驟數 | 4 步 | 3 步 | 2 步 | 1 步 | |
1. 設定解析器 2. 定義參數 3. 解析指令行 4. 業務邏輯 | 1. 定義接口描述 2. 解析指令行 3. 業務邏輯 | 1. 業務邏輯 | 1 . 業務邏輯 | ||
選項參數 (如 ) | ✔ | ||||
位置參數 | |||||
參數預設值 | ✘ | ||||
互斥選項 和 隻能二選一) | 可通過第三方庫支援 | ||||
可變參數 (如指定多個 | |||||
嵌套/父子指令 | |||||
工具箱 | |||||
鍊式指令調用 | |||||
類型限制 |
Python 的指令行庫種類繁多、各具特色,它們并非是重複造輪子的産物,其背後的思想值得學習。結合橫向對比的總結,可以選擇出符合使用場景的庫。如果幾個庫都符合,那麼就選擇你所偏愛的風格。