使用 Python 构建漂亮的命令行程序

2020-11-19 / Python

在开始构建命令行应用程序之前,让我们来看一下什么是命令行

自计算机程序创建以来,命令行程序就无处不在了,很多程序都是由命令构建的。命令行程序是一种通过命令行或者是 shell 进行操作的软件。

命令行界面(Command Line Interface,CLI)是一种可通过终端、shell 或控制台上键入命令来控制(而非使用鼠标)的用户界面。控制台是一种显示模式,其整个屏幕仅显示文本,没有图像和 GUI 控件。

根据维基百科:

CLI 是 1960 年代中期与计算机终端上的大多数计算机系统进行交互的主要方式,并且在整个 1970 年代和 1980 年代继续在 OpenVMS 、 Unix 系统和包括 MS-DOS ,CP/M 和 Apple DOS 在内的个人计算机系统上使用。 该界面通常使用命令行 shell 实现,该命令行 shell 是一个接受命令作为文本输入并将命令转换为对应的操作系统功能。

1. 为什么选择 Python ?

https://raw.githubusercontent.com/ismdeep/upload/main/images/2020/11/19/6bd5661b7670ac6dbf9d67677d3da7b0-a4764d.jpeg

Python 经常因为其灵活性和与其他语言很好的结合使用的特性而被视作胶水语言。大部分 Python 代码都是用于脚本和命令行界面。

构建这些命令行界面和工具将变得非常强大,因为它们能够让你将想到的任何事情自动化执行成为可能。

我们处于一个漂亮且交互式界面的时代,所以 UI (User Interactive,用户交互) 和 UX (User Experience ,用户体验) 至关重要。我们需要将这些内容添加到命令行中,以让人们能够实现它们。而这些已被 Heroku 等流行公司正式使用。

大量的 Python 库和模块可以帮助构建命令行应用程序,包括从参数解析和选项标记到完整的 CLI 框架。这些工具可以执行彩色输出,进度条,发送邮件等操作。

使用这些模块,你可以创建像 Heroku 以及 Node 中 Vue-init 和 NPM-init 这些漂亮的交互式命令行界面程序了。

https://raw.githubusercontent.com/ismdeep/upload/main/images/2020/11/19/28b12eb6a3964bdcb5fb33a6e65d6490-54f1c0.png

为了方便构建漂亮的 vue init 命令行程序,我建议使用 Python-inquirer ,这是 Inquirer.js 在 Python 的移植版本。

不幸的是,由于使用了 blessings 模块(一个引入了只有类 UNIX 系统才可以使用的 _cursesfcntl 模块的包),Python-inquirer 无法在 Windows 上运行。然而,有一些出色的开发人员能够将 _curses 移植到 Windows 上,但无法移植 fcntl. Windows 中的 fctnl 可替代品是 win32api.

但是,经过了在 Google 上大量的搜索之后,我碰到了一个 Python 模块,我对其进行全面修复,并将其称为 PyInquirer ,它是 python-inquirer 的替代品。而它有一个很大的好处就是:它可以在包括 Windows 在内的所有操作系统平台上使用。赞!

https://raw.githubusercontent.com/ismdeep/upload/main/images/2020/11/19/930528e6684ff1da00bf4ce8e447dd53-92bcb5.jpg

2. Python 命令行界面的基础知识

现在让我们来看看命令行界面,并在 Python 中创建一个。

命令行界面(CLI)通常以可执行文件的名称开头。你只需要在控制台中输入名称,这就是访问脚本的的主要入口点,例如 pip.

你需要根据脚本的开发方式将参数传递给脚本,它们可以是:

  1. 参数**:** 这是传递给脚本的必需参数。如果不提供,程序就会出错。例如在:pip install django 这个命令中,django 就是命令的参数。
  2. 选项**:** 顾名思义,它是一个可选参数,通常是带有名称和值组成的对,例如:pip install django --cache-dir ./my-cache-dir-cache-dir 是一个选项参数的名称,./my-cache-dir 则是值,这组成了一对,意义表示缓存目录指定为 ./my-cache-dir 这个目录。
  3. 标志**:** 这是一个特殊的选项参数,它是用来告诉脚本启用或禁用某些行为,最常见的一种可能就是 -help 了。

使用 Heroku Toolbelt 等复杂的命令行程序,其实是将访问的所有命令全部归入到主入口点了。它们通常被视为命令或者子命令

现在,让我们看以下如何使用不同的 Python 包构建智能且美观的命令行程序。

3. Argparse

Argparse 是 Python 中用于构建命令行程序自带的模块。它提供了构建简单的命令行程序的所需的所有功能。

import argparseparser = argparse.ArgumentParser(description='Add some integers.')
parser.add_argument('integers', metavar='N', type=int, nargs='+',
                    help='interger list')parser.add_argument('--sum', action='store_const',
                    const=sum, default=max,
                    help='sum the integers (default: find the max)')args = parser.parse_args()print(args.sum(args.integers))

这是一个简单的加法操作。argparse.ArgumentParser 让你能够添加程序描述到其中,而 parser.add_argument 让你添加一个命令。parser.parse_args() 则会返回一组参数,它们通常以 名称-值 对的形式出现。

例如:你可以通过 args.integers 访问 integers 的参数值。在上述脚本中,-sum 是一个可选参数, N 是位置参数。

4. Click

与 Argparse 相比,使用 Click 能够更简单的创建命令行程序。argparse 能做的,Click 同样能做。但是使用了稍微不一样的方法。它使用了 decorators 的概念,这使命令成为可以使用装饰器包装的函数。

# cli.py
import click

@click.command()
def main():
    click.echo("This is a CLI built with Click ✨")

if __name__ == "__main__":
    main()

你可以按下面的方式添加参数和选项:

# cli.py
import click

@click.command()
@click.argument('name')
@click.option('--greeting', '-g')
def main(name, greeting):
    click.echo("{}, {}".format(greeting, name))

if __name__ == "__main__":
    main()

运行上述脚本,则应有:

$ python cli.py --greeting <greeting> Oyetoke
Hey, Oyetoke

将以上所有的整合在一起,我能够构建一个简单的命令行(CLI)程序来查询 Google Books 中的图书。

import click
import requests

__author__ = "Oyetoke Toby"

@click.group()
def main():
    """
    Simple CLI for querying books on Google Books by Oyetoke Toby
    """
    pass

@main.command()
@click.argument('query')
def search(query):
    """This search and return results corresponding to the given query from Google Books"""
    url_format = '<https://www.googleapis.com/books/v1/volumes>'
    query = "+".join(query.split())

    query_params = {
        'q': query
    }

    response = requests.get(url_format, params=query_params)

    click.echo(response.json()['items']) 

@main.command()
@click.argument('id')
def get(id):
    """This return a particular book from the given id on Google Books"""
    url_format = '<https://www.googleapis.com/books/v1/volumes/{}>'
    click.echo(id)

    response = requests.get(url_format.format(id))

    click.echo(response.json())

if __name__ == "__main__":
    main()

更多有关信息,您可以从官方文档上深入了解 Click.

5. Docopt

Docopt 是一个轻量级的 Python 软件包,可以通过解析 POSIC-样式 或 Markdown 使用说明轻松地创建命令行界面。Docopt 使用多年来用于格式化帮助信息和手册页来描述命令行界面的约定。 *docopt** 中的界面描述就是这样的帮助信息,但是形式化。

Docopt 非常关心文件顶部格式化所需的文档字符串方式。在工具名称的下一行开始,文档第一部分顶部必需是 “Usage”,并且应列出你期望命令被调用的方式。

文档的第二部分开始必需是 “Options” ,这应该提供有关在 “Usage” 中标识的选项和参数的更多信息。文档字符串的内容将程序帮助文档的内容。

"""HELLO CLI
Usage:
    hello.py
    hello.py <name>
    hello.py -h|--help
    hello.py -v|--version
Options:
    <name>  Optional name argument.
    -h --help  Show this screen.
    -v --version  Show version.
"""

from docopt import docopt

def say_hello(name):
    return("Hello {}!".format(name))

if __name__ == '__main__':
    arguments = docopt(__doc__, version='DEMO 1.0')
    if arguments['<name>']:
        print(say_hello(arguments['<name>']))
    else:
        print(arguments)

6. PyInquirer

PyInquirer 是一个用于交互式命令行用户界面的模块。我们在上面看到的程序包并没有实现我们想要的 “漂亮界面” 的目标。因此,让我们来看以下如何使用 PyInquirer.

像 Inquirer.js 一样,PyInquirer 分为两个简单的步骤:

  1. 您定义问题列表并将其传递给命令行
  2. 命令行返回答案列表
from __future__ import print_function, unicode_literals
from PyInquirer import prompt
from pprint import pprint
questions = [
    {
        'type': 'input',
        'name': 'first_name',
        'message': 'What\'s your first name',
     }
]
answers = prompt(questions)
pprint(answers)

一个互动的例子

from __future__ import print_function, unicode_literals

from PyInquirer import style_from_dict, Token, prompt, Separator
from pprint import pprint

style = style_from_dict({
    Token.Separator: '#cc5454',
    Token.QuestionMark: '#673ab7 bold',
    Token.Selected: '#cc5454',  # default
    Token.Pointer: '#673ab7 bold',
    Token.Instruction: '',  # default
    Token.Answer: '#f44336 bold',
    Token.Question: '',
})

questions = [
    {
        'type': 'checkbox',
        'message': 'Select toppings',
        'name': 'toppings',
        'choices': [
            Separator('= The Meats ='),
            {
                'name': 'Ham'
            },
            {
                'name': 'Ground Meat'
            },
            {
                'name': 'Bacon'
            },
            Separator('= The Cheeses ='),
            {
                'name': 'Mozzarella',
                'checked': True
            },
            {
                'name': 'Cheddar'
            },
            {
                'name': 'Parmesan'
            },
            Separator('= The usual ='),
            {
                'name': 'Mushroom'
            },
            {
                'name': 'Tomato'
            },
            {
                'name': 'Pepperoni'
            },
            Separator('= The extras ='),
            {
                'name': 'Pineapple'
            },
            {
                'name': 'Olives',
                'disabled': 'out of stock'
            },
            {
                'name': 'Extra cheese'
            }
        ],
        'validate': lambda answer: 'You must choose at least one topping.' \\
            if len(answer) == 0 else True
    }
]

answers = prompt(questions, style=style)
pprint(answers)

结果:

https://raw.githubusercontent.com/ismdeep/upload/main/images/2020/11/19/3dda204e0e32445743d5e8b0aa11d7a6-21b129.png

让我们来看看脚本的部分内容:

style = style_from_dict({
Token.Separator: '#cc5454',
Token.QuestionMark: '#673ab7 bold',
Token.Selected: '#cc5454',  # default
Token.Pointer: '#673ab7 bold',
Token.Instruction: '',  # default
Token.Answer: '#f44336 bold',
Token.Question: '',
})

style_from_dict 被用来定义让界面自定义样式。 Token 就像一个组件,它下面有其他的组件。

我们在前面的示例中看到了 questions 列表。并将其传递到prompt 中进行处理。

你可以按照下面的示例来创建一个交互式命令行程序:

# -*- coding: utf-8 -*-

from __future__ import print_function, unicode_literals
import regex

from pprint import pprint
from PyInquirer import style_from_dict, Token, prompt
from PyInquirer import Validator, ValidationError

style = style_from_dict({
    Token.QuestionMark: '#E91E63 bold',
    Token.Selected: '#673AB7 bold',
    Token.Instruction: '',  # default
    Token.Answer: '#2196f3 bold',
    Token.Question: '',
})

class PhoneNumberValidator(Validator):
    def validate(self, document):
        ok = regex.match('^([01]{1})?[-.\\s]?\\(?(\\d{3})\\)?[-.\\s]?(\\d{3})[-.\\s]?(\\d{4})\\s?((?:#|ext\\.?\\s?|x\\.?\\s?){1}(?:\\d+)?)?$', document.text)
        if not ok:
            raise ValidationError(
                message='Please enter a valid phone number',
                cursor_position=len(document.text))  # Move cursor to end

class NumberValidator(Validator):
    def validate(self, document):
        try:
            int(document.text)
        except ValueError:
            raise ValidationError(
                message='Please enter a number',
                cursor_position=len(document.text))  # Move cursor to end

print('Hi, welcome to Python Pizza')

questions = [
    {
        'type': 'confirm',
        'name': 'toBeDelivered',
        'message': 'Is this for delivery?',
        'default': False
    },
    {
        'type': 'input',
        'name': 'phone',
        'message': 'What\'s your phone number?',
        'validate': PhoneNumberValidator
    },
    {
        'type': 'list',
        'name': 'size',
        'message': 'What size do you need?',
        'choices': ['Large', 'Medium', 'Small'],
        'filter': lambda val: val.lower()
    },
    {
        'type': 'input',
        'name': 'quantity',
        'message': 'How many do you need?',
        'validate': NumberValidator,
        'filter': lambda val: int(val)
    },
    {
        'type': 'expand',
        'name': 'toppings',
        'message': 'What about the toppings?',
        'choices': [
            {
                'key': 'p',
                'name': 'Pepperoni and cheese',
                'value': 'PepperoniCheese'
            },
            {
                'key': 'a',
                'name': 'All dressed',
                'value': 'alldressed'
            },
            {
                'key': 'w',
                'name': 'Hawaiian',
                'value': 'hawaiian'
            }
        ]
    },
    {
        'type': 'rawlist',
        'name': 'beverage',
        'message': 'You also get a free 2L beverage',
        'choices': ['Pepsi', '7up', 'Coke']
    },
    {
        'type': 'input',
        'name': 'comments',
        'message': 'Any comments on your purchase experience?',
        'default': 'Nope, all good!'
    },
    {
        'type': 'list',
        'name': 'prize',
        'message': 'For leaving a comment, you get a freebie',
        'choices': ['cake', 'fries'],
        'when': lambda answers: answers['comments'] != 'Nope, all good!'
    }
]

answers = prompt(questions, style=style)
print('Order receipt:')
pprint(answers)

结果:

https://raw.githubusercontent.com/ismdeep/upload/main/images/2020/11/19/bba36b8ea3673d380414708274d217a8-2bd40f.png

7. PyFiglet

Pyfiglet 是一个用于将字符串转换为带有艺术字体的 ASCII 文本的 Python 模块。Pyfiglet 是 FIGlet (http://www.figlet.org/) 在纯 Python 中的完整移植。

from pyfiglet import Figlet
f = Figlet(font='slant')
print f.renderText('text to render')

运行结果:

https://raw.githubusercontent.com/ismdeep/upload/main/images/2020/11/19/bfd7023cef9b4d8f735c6239a18508d3-9afe30.png

8. Clint

Clint 包含了创建命令行程序所需的一切。它支持颜色,超强的可嵌套缩紧上下文管理器,支持自定义邮件样式的引号,超强列打印以及可选的自动扩展列,等等。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from __future__ import print_function

import sys
import os

sys.path.insert(0, os.path.abspath('..'))

from clint.arguments import Args
from clint.textui import puts, colored, indent

args = Args()

with indent(4, quote='>>>'):
    puts(colored.blue('Aruments passed in: ') + str(args.all))
    puts(colored.blue('Flags detected: ') + str(args.flags))
    puts(colored.blue('Files detected: ') + str(args.files))
    puts(colored.blue('NOT Files detected: ') + str(args.not_files))
    puts(colored.blue('Grouped Arguments: ') + str(dict(args.grouped)))

print()

https://raw.githubusercontent.com/ismdeep/upload/main/images/2020/11/19/5f2a366af561dc6b9ea03a2c186b197f-0857fd.png

酷吧?我知道。

9. 其他 Python CLI 工具

Cement: 它是完整的命令行界面框架。Cement 提供了轻量且功能齐全的基础,可以构建从单个文件脚本到复杂且设计精巧的应用程序。

Cliff: Cliff 是用于构建命令行程序的框架。它使用 setuptools 入口点提供子命令,输出格式化程序和其他扩展。

Plac: Plac 是 Python 标准库 argparse 的简单封装。该库通过声明性接口隐藏了大多数复杂的东西:推断参数解析器,而不是强制性地写下来。

10. EmailCLI

将所有内容加在一起,我编写了一个简单的命令行程序,用于通过 SendGrid 发送邮件。因此,要使用以下脚本,请从 SendGrid 获取你的 API 密钥。

10.1 安装

pip install sendgrid click PyInquirer pyfiglet pyconfigstore colorama termcolor six
import os
import re

import click
import sendgrid
import six
from pyconfigstore import ConfigStore
from PyInquirer import (Token, ValidationError, Validator, print_json, prompt,
                        style_from_dict)
from sendgrid.helpers.mail import *

from pyfiglet import figlet_format

try:
    import colorama
    colorama.init()
except ImportError:
    colorama = None

try:
    from termcolor import colored
except ImportError:
    colored = None

conf = ConfigStore("EmailCLI")

style = style_from_dict({
    Token.QuestionMark: '#fac731 bold',
    Token.Answer: '#4688f1 bold',
    Token.Instruction: '',  # default
    Token.Separator: '#cc5454',
    Token.Selected: '#0abf5b',  # default
    Token.Pointer: '#673ab7 bold',
    Token.Question: '',
})

def getDefaultEmail(answer):
    try:
        from_email = conf.get("from_email")
    except KeyError, Exception:
        from_email = u""
    return from_email

def getContentType(answer, conttype):
    return answer.get("content_type").lower() == conttype.lower()

def sendMail(mailinfo):
    sg = sendgrid.SendGridAPIClient(api_key=conf.get("api_key"))
    from_email = Email(mailinfo.get("from_email"))
    to_email = Email(mailinfo.get("to_email"))
    subject = mailinfo.get("subject").title()
    content_type = "text/plain" if mailinfo.get("content_type") == "text" else "text/html"
    content = Content(content_type, mailinfo.get("content"))
    mail = Mail(from_email, subject, to_email, content)
    response = sg.client.mail.send.post(request_body=mail.get())
    return response

def log(string, color, font="slant", figlet=False):
    if colored:
        if not figlet:
            six.print_(colored(string, color))
        else:
            six.print_(colored(figlet_format(
                string, font=font), color))
    else:
        six.print_(string)

class EmailValidator(Validator):
    pattern = r"\\"?([-a-zA-Z0-9.`?{}]+@\\w+\\.\\w+)\\"?"

    def validate(self, email):
        if len(email.text):
            if re.match(self.pattern, email.text):
                return True
            else:
                raise ValidationError(
                    message="Invalid email",
                    cursor_position=len(email.text))
        else:
            raise ValidationError(
                message="You can't leave this blank",
                cursor_position=len(email.text))

class EmptyValidator(Validator):
    def validate(self, value):
        if len(value.text):
            return True
        else:
            raise ValidationError(
                message="You can't leave this blank",
                cursor_position=len(value.text))

class FilePathValidator(Validator):
    def validate(self, value):
        if len(value.text):
            if os.path.isfile(value.text):
                return True
            else:
                raise ValidationError(
                    message="File not found",
                    cursor_position=len(value.text))
        else:
            raise ValidationError(
                message="You can't leave this blank",
                cursor_position=len(value.text))

class APIKEYValidator(Validator):
    def validate(self, value):
        if len(value.text):
            sg = sendgrid.SendGridAPIClient(
                api_key=value.text)
            try:
                response = sg.client.api_keys._(value.text).get()
                if response.status_code == 200:
                    return True
            except:
                raise ValidationError(
                    message="There is an error with the API Key!",
                    cursor_position=len(value.text))
        else:
            raise ValidationError(
                message="You can't leave this blank",
                cursor_position=len(value.text))

def askAPIKEY():
    questions = [
        {
            'type': 'input',
            'name': 'api_key',
            'message': 'Enter SendGrid API Key (Only needed to provide once)',
            'validate': APIKEYValidator,
        },
    ]
    answers = prompt(questions, style=style)
    return answers

def askEmailInformation():
   
    questions = [
        {
            'type': 'input',
            'name': 'from_email',
            'message': 'From Email',
            'default': getDefaultEmail,
            'validate': EmailValidator
        },
        {
            'type': 'input',
            'name': 'to_email',
            'message': 'To Email',
            'validate': EmailValidator
        },
        {
            'type': 'input',
            'name': 'subject',
            'message': 'Subject',
            'validate': EmptyValidator
        },
        {
            'type': 'list',
            'name': 'content_type',
            'message': 'Content Type:',
            'choices': ['Text', 'HTML'],
            'filter': lambda val: val.lower()
        },
        {
            'type': 'input',
            'name': 'content',
            'message': 'Enter plain text:',
            'when': lambda answers: getContentType(answers, "text"),
            'validate': EmptyValidator
        },
        {
            'type': 'confirm',
            'name': 'confirm_content',
            'message': 'Do you want to send an html file',
            'when': lambda answers: getContentType(answers, "html")

        },
        {
            'type': 'input',
            'name': 'content',
            'message': 'Enter html:',
            'when': lambda answers: not answers.get("confirm_content", True),
            'validate': EmptyValidator
        },
        {
            'type': 'input',
            'name': 'content',
            'message': 'Enter html path:',
            'validate': FilePathValidator,
            'filter': lambda val: open(val).read(),
            'when': lambda answers: answers.get("confirm_content", False)
        },
        {
            'type': 'confirm',
            'name': 'send',
            'message': 'Do you want to send now'
        }
    ]

    answers = prompt(questions, style=style)
    return answers

@click.command()
def main():
    """
    Simple CLI for sending emails using SendGrid
    """
    log("Email CLI", color="blue", figlet=True)
    log("Welcome to Email CLI", "green")
    try:
        api_key = conf.get("api_key")
    except KeyError:
        api_key = askAPIKEY()
        conf.set(api_key)
    
    mailinfo = askEmailInformation()
    if mailinfo.get("send", False):
        conf.set("from_email", mailinfo.get("from_email"))
        try:
            response = sendMail(mailinfo)
        except Exception as e:
            raise Exception("An error occured: %s" % (e))
        
        if response.status_code == 202:
            log("Mail sent successfully", "blue")
        else:
            log("An error while trying to send", "red")

if __name__ == '__main__':
    main()

https://raw.githubusercontent.com/ismdeep/upload/main/images/2020/11/19/cf5a82fe55a7ff783b88f86c236349fc-ca9e34.png

如此而已。

可以看看这篇文章 https://www.davidfischer.name/2017/01/python-command-line-apps/

翻译自:https://codeburst.io/building-beautiful-command-line-interfaces-with-python-26c7e1bb54df