Python 交互式命令行应用

prompt-toolkit库

prompt_toolkit是一个能够建立强大的交互式命令行和终端应用的python库。

可以实现以下效果的命令行程序(这是一个官方的示例程序):

full_screen_app

其组件平时可以拿出来单独使用,也可以直接使用 Application 类来打包成一个命令行应用。

其文档可以从这边找到:readthedocs (opens new window)

我的最初需求

我最初的需求其实并没有上图这么复杂,很简单:

这是单窗口下的程序输出: initial

我希望可以做到:

  1. 命令行中可以实时输出的同时又可以实时输入。
  2. print()的消息和logging输出的日志消息可以不要混在一起。
  3. 如果可以的话,我还希望静态显示一个logo,使logo不会被日志刷上去。

爬坑指南

prompt_toolkit 可以通过非常简单的代码实现一个多窗口的命令行程序:

# import ......

# 1. The layout
left_text = "\nVertical-split example. Press 'q' to quit.\n\n(left pane.)"
right_text = "\n(right pane.)"

body = VSplit(
    [
        Window(FormattedTextControl(left_text)),
        Window(width=1, char="|"),  # Vertical line in the middle.
        Window(FormattedTextControl(right_text)),
    ]
)

# 2. Key bindings
kb = KeyBindings()

@kb.add("q")
def _(event):
    " Quit application. "
    event.app.exit()

# 3. The `Application`
application = Application(layout=Layout(body), key_bindings=kb, full_screen=True)

application.run()

上面的代码并排安放了三个垂直的窗口,其中一个特殊的窗口只有一个字符的宽度用于分隔这两个部分,另外两个窗口里面用FormattedTextControl组件显示了两行文字。

这个程序的效果如下: vertical-split

WARNING

⚠️这里需要注意的一点是,FormattedTextControl 一般用于显示静态格式化文本,如果像我这样需要显示动态文本的话就需要使用TextArea组件了。

更新组件的文字也非常简单,只需要给其text属性赋值即可。

这样子,我便可以很快实现这样一个窗口:

raw_window

问题1: 如何将print和logging的数据显示到不同的区域

这个时候,问题就出现了,我应该如何把本来直接打印在命令行窗口的文本显示到这些小窗口里面呢?

一般第一反应都是重定向,logging的重定向还算比较方便,该库提供了直接通过网络来发送log信息的功能,这样我只需要开一个socket服务接收这些log并打印在小窗口就可以了。

但是print()重定向,一般网上只能查到命令行重定向的方式。也就是说,我们只能将其先输出到文件,随后读取文件再显示,但是每隔一段时间需要轮询一次文件的方法是在是不怎么美观。

于是我去参考了 prompt-toolkit 源码中的 patch_stdout (opens new window) 部分。这一部分代码实现了将所有的捕获了所有print的信息,做到了始终将输入框保持在底部的功能:example (opens new window)

它定义了一个上下文管理器patch_stdout和一个TextIO类型的组件StdoutProxy,在它之中的所有sys.stdout都被替换成了StdoutProxy,因为所有的print函数都是指向sys.stdout的,这样就做到了捕获所有的print信息:

with patch_stdout():
    print('lalala')

这样以来,解决方法就很简单了:

  1. 先实例化一个logging自带的StreamHandler将logging的输出全部重定向到stderr
  2. 模仿patch_stdout写一个自己的patch_stdoutStdoutProxy, 将stderrstdout分别重定向到显示log的窗口和显示print的窗口。
  3. 将整个程序放到 with patch_stdout(): 中。

这样,两部分的输出就可以被分别显示到不同的窗口了。

q1

问题2: TextArea 显示带ANSI颜色的文本

从上图可以发现,我为日志设置了颜色,但是在TextArea中不显示这些颜色。这是因为这些颜色是为命令行设置的ANSI颜色,而TextArea无法识别这种定义颜色的方法。在 prompt_toolkit 库中,FormattedTextControl 其实是可以显示ANSI颜色的,奈何它只适用于显示静态文本。

TextArea 其实也是一个很强大的组件,它支持 Lexer 来对其中的文字进行语法高亮。其工作过程大概是这样的:

  1. 这里有一行代码:print('lalala'),把它丢给 Python 的Lexer
  2. 语法分析器将其转换为如下格式:
    [
        (函数名, "print"),
        (标点, "("),
        (字符串, "'lalala'"),
        (标点, ")")
    ]
    
  3. 随后,渲染每个类型的颜色
    [
        ('#0000ff', "print"),
        ('#ffffff', "("),
        ('#ffff00', "'lalala'"),
        ('#ffffff', ")")
    ]
    
  4. 获得了这样一个数组之后,TextArea 就可以显示这样一行带颜色的代码了。

也就是说,我们需要转换ANSI颜色的 Lexer 随后传给 TextArea,这样 TextArea 就可以显示ANSI颜色了。但是 prompt_toolkit 和 pygments 都没有一个支持ANSI颜色的 Lexer, 毕竟人家Lexer都是用来做语法高亮的,并不是拿来给你转换格式化颜色的。

那就只能自己定义一个了!prompt_toolkit 给了一个 Lexer 的母类,你可以继承它自己写一个 Lexer。

写完之后,传给 TextArea,就得到了这样的最终效果:

q2

@Aeonni 写于2020.04.09

转载请注明出处

Last Updated: 2021-03-30 01:00:06