一、基本介绍
本文主要介绍如何使用 Plumbum CLI 工具包来开发 Python 命令行应用程序,这是一个非常 Pythonic、容易使用、功能强大的工具包,非常值得广大 Python 程序员掌握并使用。
轻松执行程序的另一方面是轻松编写 CLI 程序。Python 脚本一般使用 optparse
或者最新的 argparse
及其衍生品来开发命令行工具,但是所有这些表现力有限,而且非常不直观(甚至不够 Pythonic)。Plumbum 的 CLI 工具包提供了一个程序化的方法来构建命令行应用程序,不需要创建一个解析器对象,然后填充一系列“选项”,该 CLI 工具包使用内省机制将这些原语转义成 Pythonic 结构。
总体来看,Plumbum CLI 应用程序是一个继承自 plumbum.cli.Application
的类。
这些类定义了一个 main()
方法,并且可选地公开出方法和属性来作为命令行的选项。这些选项可能需要参数,而任何剩余的位置参数会根据 main
函数的声明来将其赋予 main
方法。
二、安装
pip3 install plumbum
三、基本使用
一个简单的 CLI 应用程序看起来像如下这样:
# plumexample.py 文件内容如下
from plumbum import cli
class MyApp():
pass
if __name__ == "__main__":
MyApp.run()
你可以运行该程序:
$ python3 plum命令行参数解析.py -h
Usage:
plum命令行参数解析.py [SWITCHES] args...
Meta-switches:
-h, --help Prints this help message and quits
--help-all Prints help messages of all sub-commands and
quits
-v, --version Prints the program's version and quits
到现在为止,你只看到了非常基本的使用。我们现在开始探索该库。
新版本 1.6.1: 你可以直接运行应用程序 MyApp()
,不需要参数,也不需要调用 .main()
。
四、高级用法
1 应用程序
Application 类是你的应用程序的“容器”,该“容器”由一个你需要实现的main()
方法和任何数量公开选项函数和属性。你的应用程序的入口是类方法 run
,该方法实例化你的类、解析参数、调用所有的选项函数,然后使用给的位置参数来调用main()
函数。为了从命令行运行你的应用程序,你所要做的是:
if __name__ == "__main__":
MyApp.run()
除了 run()
和 main()
,Application
类还公开了两个内置的选项函数:help()
和 version()
,分别用于显示帮助和程序的版本。默认情况下,--hep
和 -h
会调用 help()
,--version
和 -v
会调用 version()
,这些函数被调用后会显示相应的信息然后退出(没有处理任何其他选项)。
你可以通过定义类属性来自定义 help()
和 version()
显示的信息,比如 PROGNAME
、 VERSION
和 DESCRIPTION
。举例:
class MyApp(cli.Application):
PROGNAME = "Foo"
VERSION = "7.3"
2 颜色
新版本 1.6
该库也支持终端字符颜色控制。你可以直接将 PROGNAME
, VERSION
和 DESCRIPTION
变为带颜色的字符串。如果你给 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
3 选项函数
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
绑定
4 选项参数
如上面例子所示,选项函数可能没有参数(不包括 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'",)
工具包包含两个额外的“类型”(或者是是验证器):Range
和 Set
。Range
指定一个最小值和最大值,限定一个整数在该范围内(闭区间)。
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
(确保给定的参数是一个不存在的路径)。
所有这些将参数转换为本地路径。
5 可重复的选项
默认情况下,在一条命令行中,选项只能指定一次,除非你给 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']
注意 选项函数只被调用一次,它的参数将会变成一个列表。
6 强制的选项
如果某个选项是必须的,你可以给 switch
装饰器传递 mandatory = True
来实现。这样的话,如果用户不指定该选项,那么程序将无法运行。
class MyApp(cli.Application):
__port = 80
@cli.switch('-p',int, mandatory=True)
def server_port(self, port):
self.__port = port
def main(self):
print(self.__port)
7 选项依赖
很多时候,一个选项的出现依赖另一个选项,比如,如果不给定 -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']
警告 选项函数的调用顺序和命令行指定的选项的顺序是一致的。目前不支持在程序运行时计算选项函数调用的拓扑顺序,但是将来会改进。
8 选项互斥
有些选项依赖其他选项,但是有些选项是和其他选项互斥的。比如,--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']
9 选项分组
如果你希望在帮助信息中将某些选项组合在一起,你可以给 switch
装饰器指定 group = "Group Name"
, Group Name
可以是任意字符串。当显示帮助信息的时候,所有属于同一个组的选项会被聚合在一起。注意,分组不影响选项的处理,但是可以增强帮助信息的可读性。
10 选项属性
很多时候只需要将选项的参数存储到类的数据属性中,或者当某个数据属性给定后设置一个标志。为此,工具包提供了 SwitchAttr
,这是一个数据描述符,用来存储参数。 还提供了两个额外的
-
cli.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.verbosity_level)
if self.enable_logging:
print(self.enable_logging)
$ ./example.py -v --log-file=log.txt -v --no-log -vvv
log.txt False 5
11 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
11 位置验证
新版本 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.1
随着 CLI 应用程序的扩展,功能变的越来越多,一个通常的做法是将其逻辑分成多个子应用(或者子命令)。一个典型的例子是版本控制系统,比如 git,git
是根命令,在这之下的子命令比如 commit
或者 push
是嵌套的。git
甚至支持命令别名,这允许用户自己创建一些子命令。Plumbum 写类似这样的程序是很轻松的。
在我们开始了解代码之前,先强调两件事情:
在 Plumbum 中,每个子命令都是一个完整的
cli.Application
应用,你可以单独执行它,或者从所谓的根命令中分离出来。当应用程序单独执行时,它的父属性是None
,当以子命令运行时,它的父属性指向父应用程序。同样,当父应用使用子命令执行时,它的内嵌命令被设置成内嵌应用。每个子命令只负责它自己的选项参数(直到下一个子命令)。这允许应用在内嵌应用调用之前来处理它自己的选项和位置参数。例如
git --foo=bar spam push origin --tags
:根应用git
负责选项--foo
和位置选项spam
,内嵌应用push
负责在它之后的参数。从理论上讲,你可以将多个子应用程序嵌套到另一个应用程序中,但在实践中,通常嵌套层级只有一层。
这是一个模仿版本控制系统的例子 geet
。我们有一个根应用 Geet
,它有两个子命令 GeetCommit
和 GeetPush
:这两个子命令通过 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)
- 在定义的应用类中,定义的任何参数不可以使用
mandatory=True
,否则会导致子命令失效
以下是运行该应用程序的示例:
$ 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
)。
如果配置文件不存在,那么将会以当前的 key
和默认的 value
来创建一个配置文件,在调用 .get
方法时会得到默认值,当上下文管理器存在时,文件会被创建。如果配置文件存在,那么该文件将会被读取并且没有任何改变。
ini 解析器默认使用 [DEFAULT]
段,就像 Python 的 ConfigParser。如果你想使用一个不同的段,只需要在 key 中通过 .
将段和标题分隔开。比如 conf['section.item']
会将 item
放置在 [section]
下。所有存储在 ConfigINI
中的条目会被转化成 str
,get()
方法返回的也是str
。
使用示例:
from plumbum import cli
with cli.Config('./myapp_rc') as conf:
one = conf.get('one', '1')
two = conf.get('two', '2')
conf.set("three", 3)
conf["four"]=[33,34]
three = conf.get("three") # 获取到设置的值
conf.write() # 假如有新设置的键值对,保存到当前文件中
print(one,two, three) # 获取到的都是字符串类型
保存后的文件内容
[DEFAULT]
one = 10
two = 2
three = 3
four = [33, 34]
[dbserver]
user = admin
port = 3306
要获取帮助列表和更多的信息请参见 api docs。