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:
A[Something goes wrong]-->B[fuck]
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
A[Broken command]-->B[Matched rules]
C-->|User selects one|D[Fixed command]
Most significant part here is matching rules, rule is a special
modules with two functions:
match(command: Command) -> bool – should return
True when 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
A[Broken command]-->|from_shell|B[Shell agnostic command]
B-->|Run in sh|C[Command instance]
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:
A[Default settings]-->B[Updated from settings.py]
B-->C[Updated from env]
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
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
ch = sys.stdin.read(1)
if ch == '\x03': # For compatibility with msvcrt.getch
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:
from thefuck.rules.cd_mkdir import match, get_new_command
from tests.utils import Command
Command(script='cd foo', stderr='cd: foo: No such file or directory'),
stderr='cd: foo: No such file or directory'),
Command(script='cd foo/bar/baz', stderr='cd: can\'t cd to foo/bar/baz')])
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 (
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
pathlib only 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.