今天收到 weekly 的邮件,其中有一篇文章就是将如何良好得组织一个 python 命令行应用,讲得不错很简单也很直接,但是,我觉得仅仅讲一下如何组织文件结构还不够,还可以再进一步,再介绍一下如何处理命令行参数,所以也就有了这篇文章。
首先,我也先来套路一番,讲一下 python 命令行应用的组织结构,通常 python 如果要安装的话都会带一个 setup.py 文件,这个文件可以将对应的 python 安装到运行的查找库中,或者安装到系统的命令查找目录中,这些都依赖于你怎么写,至于怎么写,有兴趣可以参考一下我之前写过的一篇文章:python打包发布工具。
文件组织结构
一个通用的命令行应用的目录结构正常是这样的:
foo/
├── foo/
│ ├── classmodule.py
│ ├── funcmodule.py
│ ├── __init__.py
│ └── __main__.py
├── LICENSE
├── README.md
└── setup.py
- 首先是一个大的目录,表示一个这是一个项目,应该有常写 python 代码的都比较熟悉
- 然后是真正的应用目录,里面就存放真正的应用代码
- LICENSE 和 README.md 都是提供给使用者看的,没有特别说的
- setup.py 就是安装和卸载应用使用的啦
这里除了 2 和 4 这两点需要讲解一下之外,另外两点可以不细说,那就先从 2 说起。
init 文件
这个文件在 Python 中很通用,表示这是一个模块,据说在 Python3.4 版本之后就不需要这个文件了,我一直在使用着 python2.7,短期内没有转向 python3 的需要和需求,所以没细探究。因为是只是表示模块而已,所以里面根本不需要放置代码,直接保持一个空文件即可。
main 文件
这个文件很重要了,因为我们将把它当做整个应用的入口,也就是我们的应用将会在这里开始被启动起来,调试的时候可以以它为启动文件进行调试,同时,发布的时候在 setup.py 中也是将这个文件设置为应用入口。
既然是应用的入口,那么我们和应用有关但是又不是强相关的内容就可以放置到这了,例如参数解析之类的。这里我就以我个人阅读优秀库的源码结合个人的理解,介绍一下如何编写这个 __main__
模块比较合适。
#!/usr/bin/env python
# encoding: utf-8
import sys
from .classmodule import FooCommand
def main():
argv = sys.argv
cmd = FooCommand()
cmd.execute_from_commandline(argv)
if __name__ == '__main__':
main()
可以发现其实这里就从 classmodule 中导入了一个 FooCommand 的类,然后执行一下 execute_from_commandline 方法,这里需要说明的是,其实这个 FooCommand 是继承自 Command 基类来的,为什么要这么设计?假设我们这是一个封装的网络工具包,那么我们可能一个应用程序支持很多子命令:
foo ping www.google.com
foo telnet www.google.com
对于每个自命令我们的参数的含义都是不一样的,那是不是我们都要重新写一次所有的逻辑?事实上,那么多参数和方法,不一样的可能也就执行的逻辑代码和解析参数的代码,其他代码都可以抽象成一个框架,这样就有意思了,假如设计是这样的:
图 1:整体结构 |
这里给大家解释一下这个 Command 基类中各个方法的含义:
- execute_from_commandline:这个方法是这个类的入口,调用从此开始
- handle_argv:根据命令行的参数设置我们的应用程序
- prepare_args:解析必须的参数
- parse_options:解析可选参数
- __call__ : 处理执行结果
- run:真正得执行逻辑代码
通过这些方法,我们可以看到,一半以上的方法都是可以重用的,也就是说对于一个新命令,我们仅仅只需要覆盖一些方法即可。
classmodule.py
接下来是时候看下真正的代码是怎样的了,就从基类 Command
开始吧。
#!/usr/bin/env python
# encoding: utf-8
from __future__ import absolute_import, print_function, unicode_literals
import os
import random
import sys
class Command(object):
args = ''
supports_args = True
option_list = ()
doc = None
respects_app_option = True
prog_name = 'foo'
def __init__(self):
pass
def run(self, *args, **options):
raise NotImplementedError('subclass responsibility')
def __call__(self, *args, **kwargs):
random.seed() # maybe we were forked.
self.verify_args(args)
try:
ret = self.run(*args, **kwargs)
return ret if ret is not None else 'OK'
except self.UsageError as exc:
self.on_usage_error(exc)
return exc.status
except self.Error as exc:
self.on_error(exc)
return exc.status
def verify_args(self, given, _index=0):
pass
def execute_from_commandline(self, argv=None):
if argv is None:
argv = list(sys.argv)
self.prog_name = os.path.basename(argv[0])
return self.handle_argv(self.prog_name, argv[1:])
def run_from_argv(self, prog_name, argv=None, command=None):
return self.handle_argv(prog_name,
sys.argv if argv is None else argv, command)
def usage(self, command):
return '%prog {0} [options] {self.args}'.format(command, self=self)
def get_options(self):
return self.option_list
def handle_argv(self, prog_name, argv, command=None):
options, args = self.prepare_args(
*self.parse_options(prog_name, argv, command))
return self(*args, **options)
def prepare_args(self, options, args):
pass
def check_args(self, args):
pass
def error(self, s):
pass
def die(self, msg, status='EX_FAILURE'):
self.error(msg)
sys.exit(status)
def parse_options(self, prog_name, arguments, command=None):
self.parser = self.create_parser(prog_name, command)
return self.parser.parse_args(arguments)
def create_parser(self, prog_name, command=None):
pass
def prepare_parser(self, parser):
pass
def parse_preload_options(self, args):
pass
def preparse_options(self, args, options):
pass
@property
def colored(self):
if self._colored is None:
self._colored = term.colored(enabled=not self.no_color)
return self._colored
@colored.setter
def colored(self, obj):
self._colored = obj
@property
def no_color(self):
return self._no_color
@no_color.setter
def no_color(self, value):
self._no_color = value
if self._colored is not None:
self._colored.enabled = not self._no_color
这里为了简约就去掉了一些方法的实现,具体的实现思路讲给大家听就好了,按照这个方法继续继承其他的 Command 并且填写需要实现的方法,这样就把逻辑代码给填补好了。
funcmodule.py
这个文件在我们这个sample 中没有使用到,作用呢主要用于定义各种函数,例如我们要使用的辅助函数啊之类的都可以放在这里。
setup.py
是时候来讲一下这个文件了,因为这个文件关系着我们的应用要怎么使用,告诉 Python 如何处理这个应用。我就给个简单点的实现吧,如果有需要的话,还是前面说的,参考一下 setup.py 的各个参数的意思去填:
from setuptools import setup
setup(
name = 'foo',
version = '0.1.0',
packages = ['foo'],
entry_points = {
'console_scripts': [
'foo = foo.__main__:main'
]
})
很熟悉的套路,关键就在于 console_scripts
,这个 key 的值就是我们要抛出的应用名字,当我们执行 python setup.py install
时,那么安装在系统的目录中的命令行应用应该就是 foo
了,然后我们也就可以使用 foo 命令了。
Summary
讲道理,当我们有一些繁琐的重复性工作的时候,命令行应用还是非常赞的;或者说一些我们常用的脚本,将它丰富一下,封装成应用也是很好。我们用 C 编写的话还不方便,还要编译安装之类的,也繁琐一些,而 python 的快速开发,方便修改也给我们提供了很大的方便。
如果你觉得本文的示例有点意思的话,我在 Github 上编写一个完整的可运行的 Demo,你可以来试试:项目地址