今天收到 weekly 的邮件,其中有一篇文章就是将如何良好得组织一个 python 命令行应用,讲得不错很简单也很直接,但是,我觉得仅仅讲一下如何组织文件结构还不够,还可以再进一步,再介绍一下如何处理命令行参数,所以也就有了这篇文章。

首先,我也先来套路一番,讲一下 python 命令行应用的组织结构,通常 python 如果要安装的话都会带一个 setup.py 文件,这个文件可以将对应的 python 安装到运行的查找库中,或者安装到系统的命令查找目录中,这些都依赖于你怎么写,至于怎么写,有兴趣可以参考一下我之前写过的一篇文章:TODO

文件组织结构

一个通用的命令行应用的目录结构正常是这样的:

foo/
├── foo/
│   ├── classmodule.py
│   ├── funcmodule.py
│   ├── __init__.py
│   └── __main__.py
├── LICENSE
├── README.md
└── setup.py
  1. 首先是一个大的目录,表示一个这是一个项目,应该有常写 python 代码的都比较熟悉
  2. 然后是真正的应用目录,里面就存放真正的应用代码
  3. LICENSE 和 README.md 都是提供给使用者看的,没有特别说的
  4. setup.py 就是安装和卸载应用使用的啦

这里除了 2 和 4 这两点需要讲解一下之外,另外两点可以不细说,那就先从 2 说起。

init.py

这个文件在 Python 中很通用,表示这是一个模块,据说在 Python3.4 版本之后就不需要这个文件了,我一直在使用着 python2.7,短期内没有转向 python3 的需要和需求,所以没细探究。因为是只是表示模块而已,所以里面根本不需要放置代码,直接保持一个空文件即可。

main.py

这个文件很重要了,因为我们将把它当做整个应用的入口,也就是我们的应用将会在这里开始被启动起来,调试的时候可以以它为启动文件进行调试,同时,发布的时候在 setup.py 中也是将这个文件设置为应用入口。

既然是应用的入口,那么我们和应用有关但是又不是强相关的内容就可以放置到这了,例如参数解析之类的。这里我就以我个人阅读优秀库的源码结合个人的理解,介绍一下如何编写这个 __main__ 模块比较合适。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#!/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

对于每个自命令我们的参数的含义都是不一样的,那是不是我们都要重新写一次所有的逻辑?事实上,那么多参数和方法,不一样的可能也就执行的逻辑代码解析参数的代码,其他代码都可以抽象成一个框架,这样就有意思了,假如设计是这样的:

这里给大家解释一下这个 Command 基类中各个方法的含义:

通过这些方法,我们可以看到,一半以上的方法都是可以重用的,也就是说对于一个新命令,我们仅仅只需要覆盖一些方法即可。

classmodule.py

接下来是时候看下真正的代码是怎样的了,就从基类 Command 开始吧。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
#!/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 的各个参数的意思去填:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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,你可以来试试:项目地址

Reference