Not so long ago I introduced an useful app The Fuck that fixes the previous console command. It was downloaded thousands times, got tons of stars on github, had tens of great contributors. And it’s interesting inside.
Also about a week ago I discussed about The Architecture of Open Source Applications books. And now I think it’ll be cool to write something like a chapter in the book, but about The Fuck.
The simplest abstraction for describing the app is a pipeline, from the user side it looks like just:
It’s that simple because
fuck (or whatever user uses) is an alias, it does
some magic for getting the broken command, executing fixed command and
updating the history.
For example for zsh it looks like:
TF_ALIAS=fuck alias fuck='eval $(thefuck $(fc -ln -1 | tail -n 1)); fc -R'
Back to pipeline, for
thefuck that runs inside the alias it’ll be:
And all interesting stuff happens inside of
Most significant part here is matching rules, rule is a special modules with two functions:
match(command: Command) -> bool– should return
Truewhen rule matched;
get_new_command(command: Command) -> str|list[str]– should return fixed command or list of fixed commands.
I guess the app is cool only because of the rules. And it’s very simple to write your own and now 75 rules available, written mostly by third party contributors.
Command is a special data structure that works almost like
Command(script: str, stdout: str, stderr: str)
script is a shell-agnostic version of broken command.
All shells have different ways to describe aliases, different syntax (like
fish) and different ways to work with history.
And even it depends on shell configs (
.zshrc, etc). For avoiding all this stuff a special
shells module converts
shell specific command to
sh compatible version, expands aliases and environment
So for obtaining mentioned in the previous section
Command instance we using special
run result in
sh, and obtain
And also we making some similar step with fixed command – convert
shell agnostic command to shell specific with
The Fuck is very configurable app, user can enable/disable rules,
configure ui, set rules specific options and etc. As a config app uses special
~/.thefuck/settings.py module and environment variables:
Originally settings object was passed to every place where it was
needed as an argument, it was cool and testable, but too much boilerplate.
Now it’s a singleton and works like
UI part of The Fuck is very simple, it allows to chose from variants of
corrected commands with arrows, approve selection with
dismiss it with
Downfall here is that there’s no function in Python standard library
for reading key on non-windows and without curses. And we can’t use
curses here because of alias specifics. But it’s easy to
write clone of windows-specific
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)
Also UI requires properly sorted list of corrected commands, so all rules should be matched before and it can took a long time. But with simple heuristic it works well, first of all we match rules in order of it’s priority. So the first corrected command returned by the first matched rule is definitely the command with max priority. And app matches other rules only when user presses arrow keys for selecting another. So for most use cases it work’s fast.
If we look to the app in wide, it’s very simple:
controller is an entry point, that used when user use
settings, prepares command from/to shell with
gets corrected commands from
corrector and selects one with
Corrector matches all enabled rules against current command
and returns all available corrected variants.
rules you can read above.
Tests is one of the most important parts of any software project, without them it’ll fall apart on every change. For unit tests here’s used pytest. Because of rules there’s a lot of tests for matching and checking corrected command, so parametrized tests is very useful, typical test looks like:
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)
Also The Fuck works with various amount of shells and every shell requires specific aliases. And for testing that all works we need functional tests, there’s used my pytest-docker-pexpect, that run’s special scenarios with every supported shell inside docker containers.
The most problematic part of The Fuck is installation of it by users. The app distributed with pip and we had a few problems:
- some dependencies on some platforms needs python headers (
python-dev), so we need to tell users manually install it;
- pip doesn’t support post-install hooks, so users need to manually configure an alias;
- some users uses non-supported python versions, only 2.7 and 3.3+ supported;
- some old versions of pip doesn’t install any dependency at all;
- some versions of pip ignores python version dependent dependencies, we need
pathlibonly for python older than 3.4;
- that’s funny, but someone was pissed off because of the name and tried to remove package from pypi.
Most of this problems was fixed by using special install script,
pip inside, but prepares system before installation
and configures an alias after.