不久之前我写了一款实用的应用 — The Fuck,用来修复在命令行中上一条错误的命令。这款应用下载了几千次,在 GitHub 上有很多 star,并有几十个优秀的贡献者。本文介绍应用中有趣的内部实现。
另外,大约一周前我谈论过《开源软H件架构》一书,现在我觉得要是能在书中写一章关于 The Fuck 的内容将是很酷的事情。
管道:
Fuck可以简单理解成一个管道,从用户的角度来看,流程如下:
有些东西出错了 -> fuck -> “完事”
之所以这么简单是因为 fuck 只是一个别名(alias)罢了(用户也可以使用其他别名)。对错误的命令进行了一些处理,执行修改过了的命令并更新命令历史。比如对于 zsh 是这样的:
1 |
TF_ALIAS=fuck alias fuck='eval $(thefuck $(fc -ln -1 | tail -n 1)); fc -R' |
让我们再回到管道上来,对于在别名(alias)内运行的的 Fuck 来说,管道是这样的:
出错的命令 -> thefuck -> 修复好的命令
所有有趣的事都发生在 fuck 当中:
错误的命令 -> 匹配规则 -> 修正后的命令 -> 用户选择 -> 修改好的命令
这里最重要的部分就是匹配规则了,规则是一个特殊模块集,它有两个方法:
- match(command: Command) -> bool – 匹配上规则则返回True;
- get_new_command(command: Command) -> str|list[str] – 否则返回修改后的命令或命令列表(当有多个可能匹配项)
我想这个应用只是因为它的规则才这么有趣,编写自己的规则也很简单。目前有75条可用的规则,大都是有第三方贡献者写的。命令是一个类似命名元组(namedtuple)这样的数据结构:
Command(script: str, stdout: str, stderr: str)
其中script是与shell类型无关的错误命令。
处理不同Shell类型
在不同的shell中,描述 alias 的方式不同、语法不同(比如在 fish 中 && 表示为 and)、历史命令的处理方法也不同,且 shell 还依赖特定的配置文件(.bashre ,. zshrc 等)。为了避免这些麻烦,在程序中有一个 shells 的模块把这些与特定 shell 相关的命令转化为与 sh 兼容的类型,并展开别名和环境变量。 所以我们使用 shells.from_shell 方法来获得 Command(前面的章节提到过的)的实例,在 sh 里运行并且获得stdout和stderr。
出错的命令 -> from_shell 模块 -> 与 shell 类型无关的命令 -> (可以)在 sh 内运行 –> Command实例
对修改好的命令也做了相似地处理,即把与特定 shell 无关的命令通过 shells.to_shell 模块转化为与 shell 相关的命令。
配置:
Fuck 是一个高可配置的应用,用户可以开启或关闭规则、配置 UI、设置规则选项还有进行其他的操作。用户可以通过修改 ~/.thefuck/settring.py 文件以及环境变量来配置应用:
默认配置 -> 通过 setting.py 文件更新 -> 通过环境变量更新
之前版本中,配置对象以参数的形式传递到所有需要的场合,虽然那样还不错并且能够测试,但存在过多的重复代码。而现在是一个单例(thefuck.conf.settings),类似Django中的django.conf.settings。
UI
Fuck 的UI很简单,它允许用户通过(上下)箭头的方式在修正过的命令列表中进行选择,使用 Enter 来确认选择,Ctrl+C 来跳出程序。 不足的是在 Python 标准库中没有办法在非Windows下不通过 curses 来读取键盘输入,由于别名(alias)的特性我们又不能在这里使用 curses。但容易写出针对Windows的 msvrt.getch:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
import tty import termios def getch(): fd = sys.stdin.fileno() old = termios.tcgetattr(fd) try: tty.setraw(fd) ch = sys.stdin.read(1) if ch == 'x03': # For compatibility with msvcrt.getch raise KeyboardInterrupt return ch finally: termios.tcsetattr(fd, termios.TCSADRAIN, old) |
另外UI也需要修复好的程序命令组成的有序列表,且规则匹配耗时应该尽量较短。而加入简单的启发式算法后效果还不错,首先我们按照优先级来匹配规则,第一个返回的修复过的命令是有最大优先级的命令。当用户按下箭头按键时再选择其他的命令。所以在大多数的使用场景中都能很快完成任务。
整体来看:
如果从整体来看一下这个应用,会发现它很简单:
其中 controller(控制器)是当用户使用 fuck 来修复错误命令时的程序入口,它初始化设置、准备 shells 的交互环境、从 Corrector 来获取修正过的命令并在 UI 中选择。Corrector 使用所有可用的规则来匹配当前命令并且返回所有可用的修复过的命令。关于UI、设置和规则就说到这里。
测试:
测试是所有软件项目的最重要的部分之一。没有测试,软件可能会由于任一个改变而崩溃。我们使用pytest来进行单元测试。由于应用中存在规则,所以需要做很多测试来匹配和确认修正过的命令。所以,参数化的测试用例是很有用的,典型的测试是这样的:
1 2 3 4 5 6 7 8 9 10 11 |
import pytest from thefuck.rules.cd_mkdir import match, get_new_command from tests.utils import Command @pytest.mark.parametrize('command', [ Command(script='cd foo', stderr='cd: foo: No such file or directory'), Command(script='cd foo/bar/baz', stderr='cd: foo: No such file or directory'), Command(script='cd foo/bar/baz', stderr='cd: can't cd to foo/bar/baz')]) def test_match(command): assert match(command) |
Fuck 与许多种类的 shell 共同工作,而每个 shell 又需要特定的别名。为了保证所有别名可用,需要用到功能测试,其中用到了我写的 pytest-docker-pexpect 模块,在一个 docker 容器内设置一个场景来测试所有支持的命令。
发布:
Fuck 应用的最麻烦的部分是它的安装,应用通过pip来发布,由此产生了一些问题:
- 有些平台上依赖python的头文件(python-dev),所以我们需要告诉用户手动地安装;
- pip不支持安装后自动完成一些自定义操作,所以用户需要手动配置一个别名;
- 有些用户使用不支持的python版本,应用只支持2.7或者3.3+的版本;
- 有些老版本的pip根本就不安装依赖项;
- 有些版本的pip忽视Python版本的依赖关系,所以需要为早于3.4的版本安装pathlib;
- 有趣的是有人对这个名字感到很愤怒并且尝试从pypi中移除这个包;
大多数的问题可以通过使用专门的install脚本解决,该脚本在内部使用了pip,但在安装前对系统进行一些准备工作,并在安装后配置别名。