diff --git a/README.md b/README.md index f18fafb..d3016f9 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # The Fuck [![Build Status](https://travis-ci.org/nvbn/thefuck.svg)](https://travis-ci.org/nvbn/thefuck) +**Aliases changed in 1.34.** + Magnificent app which corrects your previous console command, inspired by a [@liamosaur](https://twitter.com/liamosaur/) [tweet](https://twitter.com/liamosaur/status/506975850596536320). @@ -71,7 +73,7 @@ REPL-y 0.3.1 ... ``` -If you are scared to blindly run changed command, there's `require_confirmation` +If you are scared to blindly run the changed command, there is a `require_confirmation` [settings](#settings) option: ```bash @@ -88,8 +90,8 @@ Reading package lists... Done ## Requirements +- python (2.7+ or 3.3+) - pip -- python - python-dev ## Installation @@ -100,40 +102,30 @@ Install `The Fuck` with `pip`: sudo pip install thefuck ``` -If it fails try to use `easy_install`: +[Or using an OS package manager (OS X, Ubuntu, Arch).](https://github.com/nvbn/thefuck/wiki/Installation) + +And add to the `.bashrc` or `.bash_profile`(for OSX): ```bash -sudo easy_install thefuck -``` - -And add to `.bashrc` or `.zshrc` or `.bash_profile`(for OSX): - -```bash -alias fuck='$(thefuck $(fc -ln -1))' -# You can use whatever you want as an alias, like for mondays: +alias fuck='eval $(thefuck $(fc -ln -1)); history -r' +# You can use whatever you want as an alias, like for Mondays: alias FUCK='fuck' ``` -Or in `config.fish`: +Or in your `.zshrc`: -```fish -function fuck - eval (thefuck $history[1]) -end +```bash +alias fuck='eval $(thefuck $(fc -ln -1 | tail -n 1)); fc -R' ``` -Or in your Powershell `$PROFILE` on Windows: +Alternatively, you can redirect the output of `thefuck-alias`: -```powershell -function fuck { - $fuck = $(thefuck (get-history -count 1).commandline) - if($fuck.startswith("echo")) { - $fuck.substring(5) - } - else { iex "$fuck" } -} +```bash +thefuck-alias >> ~/.bashrc ``` +[Or in your shell config (Bash, Zsh, Fish, Powershell).](https://github.com/nvbn/thefuck/wiki/Shell-aliases) + Changes will be available only in a new shell session. @@ -145,21 +137,41 @@ sudo pip install thefuck --upgrade ## How it works -The Fuck tries to match rule for the previous command, create new command -using matched rule and run it. Rules enabled by default: +The Fuck tries to match a rule for the previous command, creates a new command +using the matched rule and runs it. Rules enabled by default are as follows: +* `brew_unknown_command` – fixes wrong brew commands, for example `brew docto/brew doctor`; +* `cpp11` – add missing `-std=c++11` to `g++` or `clang++`; * `cd_parent` – changes `cd..` to `cd ..`; +* `cd_mkdir` – creates directories before cd'ing into them; * `cp_omitting_directory` – adds `-a` when you `cp` directory; +* `dry` – fix repetitions like "git git push"; +* `fix_alt_space` – replaces Alt+Space with Space character; +* `git_add` – fix *"Did you forget to 'git add'?"*; +* `git_checkout` – creates the branch before checking-out; * `git_no_command` – fixes wrong git commands like `git brnch`; * `git_push` – adds `--set-upstream origin $branch` to previous failed `git push`; * `has_exists_script` – prepends `./` when script/binary exists; * `lein_not_task` – fixes wrong `lein` tasks like `lein rpl`; * `mkdir_p` – adds `-p` when you trying to create directory without parent; * `no_command` – fixes wrong console commands, for example `vom/vim`; +* `man_no_space` – fixes man commands without spaces, for example `mandiff`; +* `pacman` – installs app with `pacman` or `yaourt` if it is not installed; +* `pip_unknown_command` – fixes wrong pip commands, for example `pip instatl/pip install`; * `python_command` – prepends `python` when you trying to run not executable/without `./` python script; +* `sl_ls` – changes `sl` to `ls`; * `rm_dir` – adds `-rf` when you trying to remove directory; +* `ssh_known_hosts` – removes host from `known_hosts` on warning; * `sudo` – prepends `sudo` to previous command if it failed because of permissions; -* `switch_layout` – switches command from your local layout to en. +* `switch_layout` – switches command from your local layout to en; +* `apt_get` – installs app from apt if it not installed; +* `brew_install` – fixes formula name for `brew install`; +* `composer_not_command` – fixes composer command name. + +Bundled, but not enabled by default: + +* `ls_lah` – adds -lah to ls; +* `rm_root` – adds `--no-preserve-root` to `rm -rf /` command. ## Creating your own rules @@ -167,10 +179,13 @@ For adding your own rule you should create `your-rule-name.py` in `~/.thefuck/rules`. Rule should contain two functions: `match(command: Command, settings: Settings) -> bool` and `get_new_command(command: Command, settings: Settings) -> str`. +Also the rule can contain optional function +`side_effect(command: Command, settings: Settings) -> None` and +optional boolean `enabled_by_default` `Command` has three attributes: `script`, `stdout` and `stderr`. -`Settings` is `~/.thefuck/settings.py`. +`Settings` is a special object filled with `~/.thefuck/settings.py` and values from env, [more](#settings). Simple example of the rule for running script with `sudo`: @@ -182,6 +197,14 @@ def match(command, settings): def get_new_command(command, settings): return 'sudo {}'.format(command.script) + +# Optional: +enabled_by_default = True + +def side_effect(command, settings): + subprocess.call('chmod 777 .', shell=True) + +priority = 1000 # Lower first ``` [More examples of rules](https://github.com/nvbn/thefuck/tree/master/thefuck/rules), @@ -189,12 +212,42 @@ def get_new_command(command, settings): ## Settings -The Fuck has a few settings parameters, they can be changed in `~/.thefuck/settings.py`: +The Fuck has a few settings parameters which can be changed in `~/.thefuck/settings.py`: -* `rules` – list of enabled rules, by default all; -* `require_confirmation` – require confirmation before running new command, by default `False`; +* `rules` – list of enabled rules, by default `thefuck.conf.DEFAULT_RULES`; +* `require_confirmation` – requires confirmation before running new command, by default `False`; * `wait_command` – max amount of time in seconds for getting previous command output; -* `no_colors` – disable colored output. +* `no_colors` – disable colored output; +* `priority` – dict with rules priorities, rule with lower `priority` will be matched first. + +Example of `settings.py`: + +```python +rules = ['sudo', 'no_command'] +require_confirmation = True +wait_command = 10 +no_colors = False +priority = {'sudo': 100, 'no_command': 9999} +``` + +Or via environment variables: + +* `THEFUCK_RULES` – list of enabled rules, like `DEFAULT_RULES:rm_root` or `sudo:no_command`; +* `THEFUCK_REQUIRE_CONFIRMATION` – require confirmation before running new command, `true/false`; +* `THEFUCK_WAIT_COMMAND` – max amount of time in seconds for getting previous command output; +* `THEFUCK_NO_COLORS` – disable colored output, `true/false`; +* `THEFUCK_PRIORITY` – priority of the rules, like `no_command=9999:apt_get=100`, +rule with lower `priority` will be matched first. + +For example: + +```bash +export THEFUCK_RULES='sudo:no_command' +export THEFUCK_REQUIRE_CONFIRMATION='true' +export THEFUCK_WAIT_COMMAND=10 +export THEFUCK_NO_COLORS='false' +export THEFUCK_PRIORITY='no_command=9999:apt_get=100' +``` ## Developing diff --git a/release.py b/release.py index e22209b..85af928 100755 --- a/release.py +++ b/release.py @@ -28,4 +28,4 @@ call('git commit -am "Bump to {}"'.format(version), shell=True) call('git tag {}'.format(version), shell=True) call('git push', shell=True) call('git push --tags', shell=True) -call('python setup.py sdist upload', shell=True) +call('python setup.py sdist bdist_wheel upload', shell=True) diff --git a/requirements.txt b/requirements.txt index 625bed6..78b43ee 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,4 @@ pytest mock +pytest-mock +wheel diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..2a9acf1 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal = 1 diff --git a/setup.py b/setup.py index 701e9af..c79654f 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ from setuptools import setup, find_packages -VERSION = '1.26' +VERSION = '1.39' setup(name='thefuck', @@ -15,6 +15,7 @@ setup(name='thefuck', 'tests', 'release']), include_package_data=True, zip_safe=False, - install_requires=['pathlib', 'psutil', 'colorama'], + install_requires=['pathlib', 'psutil', 'colorama', 'six'], entry_points={'console_scripts': [ - 'thefuck = thefuck.main:main']}) + 'thefuck = thefuck.main:main', + 'thefuck-alias = thefuck.shells:app_alias']}) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/rules/test_brew_install.py b/tests/rules/test_brew_install.py new file mode 100644 index 0000000..2ea58f1 --- /dev/null +++ b/tests/rules/test_brew_install.py @@ -0,0 +1,49 @@ +import pytest +from thefuck.rules.brew_install import match, get_new_command +from thefuck.rules.brew_install import brew_formulas +from tests.utils import Command + + +@pytest.fixture +def brew_no_available_formula(): + return '''Error: No available formula for elsticsearch ''' + + +@pytest.fixture +def brew_install_no_argument(): + return '''This command requires a formula argument''' + + +@pytest.fixture +def brew_already_installed(): + return '''Warning: git-2.3.5 already installed''' + + +def _is_not_okay_to_test(): + if 'elasticsearch' not in brew_formulas: + return True + return False + + +@pytest.mark.skipif(_is_not_okay_to_test(), + reason='No need to run if there\'s no formula') +def test_match(brew_no_available_formula, brew_already_installed, + brew_install_no_argument): + assert match(Command('brew install elsticsearch', + stderr=brew_no_available_formula), None) + assert not match(Command('brew install git', + stderr=brew_already_installed), None) + assert not match(Command('brew install', stderr=brew_install_no_argument), + None) + + +@pytest.mark.skipif(_is_not_okay_to_test(), + reason='No need to run if there\'s no formula') +def test_get_new_command(brew_no_available_formula): + assert get_new_command(Command('brew install elsticsearch', + stderr=brew_no_available_formula), None)\ + == 'brew install elasticsearch' + + assert get_new_command(Command('brew install aa', + stderr=brew_no_available_formula), + None) != 'brew install aha' diff --git a/tests/rules/test_brew_unknown_command.py b/tests/rules/test_brew_unknown_command.py new file mode 100644 index 0000000..d9a79c3 --- /dev/null +++ b/tests/rules/test_brew_unknown_command.py @@ -0,0 +1,28 @@ +import pytest +from thefuck.rules.brew_unknown_command import match, get_new_command +from thefuck.rules.brew_unknown_command import brew_commands +from tests.utils import Command + + +@pytest.fixture +def brew_unknown_cmd(): + return '''Error: Unknown command: inst''' + + +@pytest.fixture +def brew_unknown_cmd2(): + return '''Error: Unknown command: instaa''' + + +def test_match(brew_unknown_cmd): + assert match(Command('brew inst', stderr=brew_unknown_cmd), None) + for command in brew_commands: + assert not match(Command('brew ' + command), None) + + +def test_get_new_command(brew_unknown_cmd, brew_unknown_cmd2): + assert get_new_command(Command('brew inst', stderr=brew_unknown_cmd), + None) == 'brew list' + + assert get_new_command(Command('brew instaa', stderr=brew_unknown_cmd2), + None) == 'brew install' diff --git a/tests/rules/test_cd_mkdir.py b/tests/rules/test_cd_mkdir.py new file mode 100644 index 0000000..ae5449f --- /dev/null +++ b/tests/rules/test_cd_mkdir.py @@ -0,0 +1,25 @@ +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, None) + + +@pytest.mark.parametrize('command', [ + Command(script='cd foo', stderr=''), Command()]) +def test_not_match(command): + assert not match(command, None) + + +@pytest.mark.parametrize('command, new_command', [ + (Command('cd foo'), 'mkdir -p foo && cd foo'), + (Command('cd foo/bar/baz'), 'mkdir -p foo/bar/baz && cd foo/bar/baz')]) +def test_get_new_command(command, new_command): + assert get_new_command(command, None) == new_command diff --git a/tests/rules/test_cd_parent.py b/tests/rules/test_cd_parent.py index 7a0fbc8..61d1ab1 100644 --- a/tests/rules/test_cd_parent.py +++ b/tests/rules/test_cd_parent.py @@ -1,12 +1,12 @@ -from thefuck.main import Command from thefuck.rules.cd_parent import match, get_new_command +from tests.utils import Command def test_match(): - assert match(Command('cd..', '', 'cd..: command not found'), None) - assert not match(Command('', '', ''), None) + assert match(Command('cd..', stderr='cd..: command not found'), None) + assert not match(Command(), None) def test_get_new_command(): assert get_new_command( - Command('cd..', '', ''), None) == 'cd ..' + Command('cd..'), None) == 'cd ..' diff --git a/tests/rules/test_composer_not_command.py b/tests/rules/test_composer_not_command.py new file mode 100644 index 0000000..15e630d --- /dev/null +++ b/tests/rules/test_composer_not_command.py @@ -0,0 +1,53 @@ +import pytest +from thefuck.rules.composer_not_command import match, get_new_command +from tests.utils import Command + + +@pytest.fixture +def composer_not_command(): + return """ + + + [InvalidArgumentException] + Command "udpate" is not defined. + Did you mean this? + update + + +""" + + +@pytest.fixture +def composer_not_command_one_of_this(): + return """ + + + + [InvalidArgumentException] + Command "pdate" is not defined. + Did you mean one of these? + selfupdate + self-update + update + + + +""" + + +def test_match(composer_not_command, composer_not_command_one_of_this): + assert match(Command('composer udpate', + stderr=composer_not_command), None) + assert match(Command('composer pdate', + stderr=composer_not_command_one_of_this), None) + assert not match(Command('ls update', stderr=composer_not_command), + None) + + +def test_get_new_command(composer_not_command, composer_not_command_one_of_this): + assert get_new_command(Command('composer udpate', + stderr=composer_not_command), None) \ + == 'composer update' + assert get_new_command( + Command('composer pdate', stderr=composer_not_command_one_of_this), + None) == 'composer selfupdate' diff --git a/tests/rules/test_dry.py b/tests/rules/test_dry.py new file mode 100644 index 0000000..757866d --- /dev/null +++ b/tests/rules/test_dry.py @@ -0,0 +1,17 @@ +import pytest +from thefuck.rules.dry import match, get_new_command +from tests.utils import Command + + +@pytest.mark.parametrize('command', [ + Command(script='cd cd foo'), + Command(script='git git push origin/master')]) +def test_match(command): + assert match(command, None) + + +@pytest.mark.parametrize('command, new_command', [ + (Command('cd cd foo'), 'cd foo'), + (Command('git git push origin/master'), 'git push origin/master')]) +def test_get_new_command(command, new_command): + assert get_new_command(command, None) == new_command diff --git a/tests/rules/test_fix_alt_space.py b/tests/rules/test_fix_alt_space.py new file mode 100644 index 0000000..c27d3d2 --- /dev/null +++ b/tests/rules/test_fix_alt_space.py @@ -0,0 +1,22 @@ +# -*- encoding: utf-8 -*- + +from thefuck.rules.fix_alt_space import match, get_new_command +from tests.utils import Command + + +def test_match(): + """The character before 'grep' is Alt+Space, which happens frequently + on the Mac when typing the pipe character (Alt+7), and holding the Alt + key pressed for longer than necessary. + + """ + assert match(Command(u'ps -ef | grep foo', + stderr=u'-bash:  grep: command not found'), None) + assert not match(Command('ps -ef | grep foo'), None) + assert not match(Command(), None) + + +def test_get_new_command(): + """ Replace the Alt+Space character by a simple space """ + assert get_new_command(Command(u'ps -ef | grep foo'), None)\ + == 'ps -ef | grep foo' diff --git a/tests/rules/test_git_not_command.py b/tests/rules/test_git_not_command.py index 657953f..d93d133 100644 --- a/tests/rules/test_git_not_command.py +++ b/tests/rules/test_git_not_command.py @@ -1,6 +1,6 @@ import pytest -from thefuck.main import Command from thefuck.rules.git_not_command import match, get_new_command +from tests.utils import Command @pytest.fixture @@ -31,14 +31,14 @@ def git_command(): def test_match(git_not_command, git_command, git_not_command_one_of_this): - assert match(Command('git brnch', '', git_not_command), None) - assert match(Command('git st', '', git_not_command_one_of_this), None) - assert not match(Command('ls brnch', '', git_not_command), None) - assert not match(Command('git branch', '', git_command), None) + assert match(Command('git brnch', stderr=git_not_command), None) + assert match(Command('git st', stderr=git_not_command_one_of_this), None) + assert not match(Command('ls brnch', stderr=git_not_command), None) + assert not match(Command('git branch', stderr=git_command), None) def test_get_new_command(git_not_command, git_not_command_one_of_this): - assert get_new_command(Command('git brnch', '', git_not_command), None)\ + assert get_new_command(Command('git brnch', stderr=git_not_command), None)\ == 'git branch' - assert get_new_command( - Command('git st', '', git_not_command_one_of_this), None) == 'git status' + assert get_new_command(Command('git st', stderr=git_not_command_one_of_this), + None) == 'git status' diff --git a/tests/rules/test_git_push.py b/tests/rules/test_git_push.py index 7504a72..7a2b512 100644 --- a/tests/rules/test_git_push.py +++ b/tests/rules/test_git_push.py @@ -1,6 +1,6 @@ import pytest -from thefuck.main import Command from thefuck.rules.git_push import match, get_new_command +from tests.utils import Command @pytest.fixture @@ -14,11 +14,11 @@ To push the current branch and set the remote as upstream, use def test_match(stderr): - assert match(Command('git push master', '', stderr), None) - assert not match(Command('git push master', '', ''), None) - assert not match(Command('ls', '', stderr), None) + assert match(Command('git push master', stderr=stderr), None) + assert not match(Command('git push master'), None) + assert not match(Command('ls', stderr=stderr), None) def test_get_new_command(stderr): - assert get_new_command(Command('', '', stderr), None)\ + assert get_new_command(Command(stderr=stderr), None)\ == "git push --set-upstream origin master" diff --git a/tests/rules/test_has_exists_script.py b/tests/rules/test_has_exists_script.py index 36938be..e00c4bd 100644 --- a/tests/rules/test_has_exists_script.py +++ b/tests/rules/test_has_exists_script.py @@ -1,5 +1,5 @@ from mock import Mock, patch -from thefuck.rules. has_exists_script import match, get_new_command +from thefuck.rules.has_exists_script import match, get_new_command def test_match(): diff --git a/tests/rules/test_ls_lah.py b/tests/rules/test_ls_lah.py new file mode 100644 index 0000000..c453f6e --- /dev/null +++ b/tests/rules/test_ls_lah.py @@ -0,0 +1,13 @@ +from mock import patch, Mock +from thefuck.rules.ls_lah import match, get_new_command + + +def test_match(): + assert match(Mock(script='ls file.py'), None) + assert match(Mock(script='ls /opt'), None) + assert not match(Mock(script='ls -lah /opt'), None) + + +def test_get_new_command(): + assert get_new_command(Mock(script='ls file.py'), None) == 'ls -lah file.py' + assert get_new_command(Mock(script='ls'), None) == 'ls -lah' diff --git a/tests/rules/test_man_no_space.py b/tests/rules/test_man_no_space.py new file mode 100644 index 0000000..669ebb8 --- /dev/null +++ b/tests/rules/test_man_no_space.py @@ -0,0 +1,12 @@ +from thefuck.rules.man_no_space import match, get_new_command +from tests.utils import Command + + +def test_match(): + assert match(Command('mandiff', stderr='mandiff: command not found'), None) + assert not match(Command(), None) + + +def test_get_new_command(): + assert get_new_command( + Command('mandiff'), None) == 'man diff' diff --git a/tests/rules/test_mkdir_p.py b/tests/rules/test_mkdir_p.py index 128be2f..d1b91a7 100644 --- a/tests/rules/test_mkdir_p.py +++ b/tests/rules/test_mkdir_p.py @@ -1,13 +1,22 @@ -from thefuck.main import Command +import pytest from thefuck.rules.mkdir_p import match, get_new_command +from tests.utils import Command def test_match(): - assert match(Command('mkdir foo/bar/baz', '', 'mkdir: foo/bar: No such file or directory'), None) - assert not match(Command('mkdir foo/bar/baz', '', ''), None) - assert not match(Command('mkdir foo/bar/baz', '', 'foo bar baz'), None) - assert not match(Command('', '', ''), None) + assert match(Command('mkdir foo/bar/baz', + stderr='mkdir: foo/bar: No such file or directory'), + None) + + +@pytest.mark.parametrize('command', [ + Command('mkdir foo/bar/baz'), + Command('mkdir foo/bar/baz', stderr='foo bar baz'), + Command()]) +def test_not_match(command): + assert not match(command, None) def test_get_new_command(): - assert get_new_command(Command('mkdir foo/bar/baz', '', ''), None) == 'mkdir -p foo/bar/baz' + assert get_new_command(Command('mkdir foo/bar/baz'), None)\ + == 'mkdir -p foo/bar/baz' diff --git a/tests/rules/test_pip_unknown_command.py b/tests/rules/test_pip_unknown_command.py new file mode 100644 index 0000000..58ba2bb --- /dev/null +++ b/tests/rules/test_pip_unknown_command.py @@ -0,0 +1,25 @@ +import pytest +from thefuck.rules.pip_unknown_command import match, get_new_command +from tests.utils import Command + + +@pytest.fixture +def pip_unknown_cmd(): + return '''ERROR: unknown command "instatl" - maybe you meant "install"''' + + +@pytest.fixture +def pip_unknown_cmd_without_recommend(): + return '''ERROR: unknown command "i"''' + + +def test_match(pip_unknown_cmd, pip_unknown_cmd_without_recommend): + assert match(Command('pip instatl', stderr=pip_unknown_cmd), None) + assert not match(Command('pip i', + stderr=pip_unknown_cmd_without_recommend), + None) + + +def test_get_new_command(pip_unknown_cmd): + assert get_new_command(Command('pip instatl', stderr=pip_unknown_cmd), + None) == 'pip install' diff --git a/tests/rules/test_python_command.py b/tests/rules/test_python_command.py index e807126..54be39a 100644 --- a/tests/rules/test_python_command.py +++ b/tests/rules/test_python_command.py @@ -1,9 +1,12 @@ -from thefuck.main import Command from thefuck.rules.python_command import match, get_new_command +from tests.utils import Command + def test_match(): - assert match(Command('temp.py', '', 'Permission denied'), None) - assert not match(Command('', '', ''), None) + assert match(Command('temp.py', stderr='Permission denied'), None) + assert not match(Command(), None) + def test_get_new_command(): - assert get_new_command(Command('./test_sudo.py', '', ''), None) == 'python ./test_sudo.py' + assert get_new_command(Command('./test_sudo.py'), None)\ + == 'python ./test_sudo.py' diff --git a/tests/rules/test_rm_dir.py b/tests/rules/test_rm_dir.py index 2362d0c..c334d2f 100644 --- a/tests/rules/test_rm_dir.py +++ b/tests/rules/test_rm_dir.py @@ -1,12 +1,20 @@ -from thefuck.main import Command +import pytest from thefuck.rules.rm_dir import match, get_new_command +from tests.utils import Command -def test_match(): - assert match(Command('rm foo', '', 'rm: foo: is a directory'), None) - assert not match(Command('rm foo', '', ''), None) - assert not match(Command('rm foo', '', 'foo bar baz'), None) - assert not match(Command('', '', ''), None) +@pytest.mark.parametrize('command', [ + Command('rm foo', stderr='rm: foo: is a directory'), + Command('rm foo', stderr='rm: foo: Is a directory')]) +def test_match(command): + assert match(command, None) + assert match(command, None) + + +@pytest.mark.parametrize('command', [ + Command('rm foo'), Command('rm foo'), Command()]) +def test_not_match(command): + assert not match(command, None) def test_get_new_command(): diff --git a/tests/rules/test_rm_root.py b/tests/rules/test_rm_root.py new file mode 100644 index 0000000..f56595f --- /dev/null +++ b/tests/rules/test_rm_root.py @@ -0,0 +1,21 @@ +import pytest +from thefuck.rules.rm_root import match, get_new_command +from tests.utils import Command + + +def test_match(): + assert match(Command(script='rm -rf /', + stderr='add --no-preserve-root'), None) + + +@pytest.mark.parametrize('command', [ + Command(script='ls', stderr='add --no-preserve-root'), + Command(script='rm --no-preserve-root /', stderr='add --no-preserve-root'), + Command(script='rm -rf /', stderr='')]) +def test_not_match(command): + assert not match(command, None) + + +def test_get_new_command(): + assert get_new_command(Command(script='rm -rf /'), None) \ + == 'rm -rf / --no-preserve-root' diff --git a/tests/rules/test_sl_ls.py b/tests/rules/test_sl_ls.py new file mode 100644 index 0000000..4ed7499 --- /dev/null +++ b/tests/rules/test_sl_ls.py @@ -0,0 +1,12 @@ + +from thefuck.rules.sl_ls import match, get_new_command +from tests.utils import Command + + +def test_match(): + assert match(Command('sl'), None) + assert not match(Command('ls'), None) + + +def test_get_new_command(): + assert get_new_command(Command('sl'), None) == 'ls' diff --git a/tests/rules/test_ssh_known_host.py b/tests/rules/test_ssh_known_host.py index 9c8dc0d..ed414bc 100644 --- a/tests/rules/test_ssh_known_host.py +++ b/tests/rules/test_ssh_known_host.py @@ -1,8 +1,9 @@ import os import pytest from mock import Mock -from thefuck.main import Command -from thefuck.rules.ssh_known_hosts import match, get_new_command, remove_offending_keys +from thefuck.rules.ssh_known_hosts import match, get_new_command,\ + side_effect +from tests.utils import Command @pytest.fixture @@ -43,27 +44,23 @@ Host key verification failed.""".format(path, '98.765.432.321') def test_match(ssh_error): errormsg, _, _, _ = ssh_error - assert match(Command('ssh', '', errormsg), None) - assert match(Command('ssh', '', errormsg), None) - assert match(Command('scp something something', '', errormsg), None) - assert match(Command('scp something something', '', errormsg), None) - assert not match(Command('', '', errormsg), None) - assert not match(Command('notssh', '', errormsg), None) - assert not match(Command('ssh', '', ''), None) + assert match(Command('ssh', stderr=errormsg), None) + assert match(Command('ssh', stderr=errormsg), None) + assert match(Command('scp something something', stderr=errormsg), None) + assert match(Command('scp something something', stderr=errormsg), None) + assert not match(Command(stderr=errormsg), None) + assert not match(Command('notssh', stderr=errormsg), None) + assert not match(Command('ssh'), None) -def test_remove_offending_keys(ssh_error): +def test_side_effect(ssh_error): errormsg, path, reset, known_hosts = ssh_error - command = Command('ssh user@host', '', errormsg) - remove_offending_keys(command, None) + command = Command('ssh user@host', stderr=errormsg) + side_effect(command, None) expected = ['123.234.567.890 asdjkasjdakjsd\n', '111.222.333.444 qwepoiwqepoiss\n'] assert known_hosts(path) == expected def test_get_new_command(ssh_error, monkeypatch): errormsg, _, _, _ = ssh_error - - method = Mock() - monkeypatch.setattr('thefuck.rules.ssh_known_hosts.remove_offending_keys', method) - assert get_new_command(Command('ssh user@host', '', errormsg), None) == 'ssh user@host' - assert method.call_count + assert get_new_command(Command('ssh user@host', stderr=errormsg), None) == 'ssh user@host' diff --git a/tests/rules/test_sudo.py b/tests/rules/test_sudo.py index 4f0c831..7c48d91 100644 --- a/tests/rules/test_sudo.py +++ b/tests/rules/test_sudo.py @@ -1,13 +1,21 @@ -from thefuck.main import Command +import pytest from thefuck.rules.sudo import match, get_new_command +from tests.utils import Command -def test_match(): - assert match(Command('', '', 'Permission denied'), None) - assert match(Command('', '', 'permission denied'), None) - assert match(Command('', '', "npm ERR! Error: EACCES, unlink"), None) - assert not match(Command('', '', ''), None) +@pytest.mark.parametrize('stderr, stdout', [ + ('Permission denied', ''), + ('permission denied', ''), + ("npm ERR! Error: EACCES, unlink", ''), + ('requested operation requires superuser privilege', ''), + ('', "error: [Errno 13] Permission denied: '/usr/local/lib/python2.7/dist-packages/ipaddr.py'")]) +def test_match(stderr, stdout): + assert match(Command(stderr=stderr, stdout=stdout), None) + + +def test_not_match(): + assert not match(Command(), None) def test_get_new_command(): - assert get_new_command(Command('ls', '', ''), None) == 'sudo ls' + assert get_new_command(Command('ls'), None) == 'sudo ls' diff --git a/tests/rules/test_switch_lang.py b/tests/rules/test_switch_lang.py index 991f398..12163d4 100644 --- a/tests/rules/test_switch_lang.py +++ b/tests/rules/test_switch_lang.py @@ -1,25 +1,27 @@ # -*- encoding: utf-8 -*- -from mock import Mock +import pytest from thefuck.rules import switch_lang +from tests.utils import Command -def test_match(): - assert switch_lang.match(Mock(stderr='command not found: фзе-пуе', - script=u'фзе-пуе'), None) - assert switch_lang.match(Mock(stderr='command not found: λσ', - script=u'λσ'), None) - - assert not switch_lang.match(Mock(stderr='command not found: pat-get', - script=u'pat-get'), None) - assert not switch_lang.match(Mock(stderr='command not found: ls', - script=u'ls'), None) - assert not switch_lang.match(Mock(stderr='some info', - script=u'фзе-пуе'), None) +@pytest.mark.parametrize('command', [ + Command(stderr='command not found: фзе-пуе', script=u'фзе-пуе'), + Command(stderr='command not found: λσ', script=u'λσ')]) +def test_match(command): + assert switch_lang.match(command, None) -def test_get_new_command(): - assert switch_lang.get_new_command( - Mock(script=u'фзе-пуе штыефдд мшь'), None) == 'apt-get install vim' - assert switch_lang.get_new_command( - Mock(script=u'λσ -λα'), None) == 'ls -la' +@pytest.mark.parametrize('command', [ + Command(stderr='command not found: pat-get', script=u'pat-get'), + Command(stderr='command not found: ls', script=u'ls'), + Command(stderr='some info', script=u'фзе-пуе')]) +def test_not_match(command): + assert not switch_lang.match(command, None) + + +@pytest.mark.parametrize('command, new_command', [ + (Command(u'фзе-пуе штыефдд мшь'), 'apt-get install vim'), + (Command(u'λσ -λα'), 'ls -la')]) +def test_get_new_command(command, new_command): + assert switch_lang.get_new_command(command, None) == new_command diff --git a/tests/test_conf.py b/tests/test_conf.py new file mode 100644 index 0000000..1fde6a6 --- /dev/null +++ b/tests/test_conf.py @@ -0,0 +1,102 @@ +import pytest +import six +from mock import Mock +from thefuck import conf +from tests.utils import Rule + + +@pytest.mark.parametrize('enabled, rules, result', [ + (True, conf.DEFAULT_RULES, True), + (False, conf.DEFAULT_RULES, False), + (False, conf.DEFAULT_RULES + ['test'], True)]) +def test_default(enabled, rules, result): + assert (Rule('test', enabled_by_default=enabled) in rules) == result + + +@pytest.fixture +def load_source(mocker): + return mocker.patch('thefuck.conf.load_source') + + +@pytest.fixture +def environ(monkeypatch): + data = {} + monkeypatch.setattr('thefuck.conf.os.environ', data) + return data + + +@pytest.mark.usefixture('environ') +def test_settings_defaults(load_source): + load_source.return_value = object() + for key, val in conf.DEFAULT_SETTINGS.items(): + assert getattr(conf.get_settings(Mock()), key) == val + + +@pytest.mark.usefixture('environ') +class TestSettingsFromFile(object): + def test_from_file(self, load_source): + load_source.return_value = Mock(rules=['test'], + wait_command=10, + require_confirmation=True, + no_colors=True, + priority={'vim': 100}) + settings = conf.get_settings(Mock()) + assert settings.rules == ['test'] + assert settings.wait_command == 10 + assert settings.require_confirmation is True + assert settings.no_colors is True + assert settings.priority == {'vim': 100} + + def test_from_file_with_DEFAULT(self, load_source): + load_source.return_value = Mock(rules=conf.DEFAULT_RULES + ['test'], + wait_command=10, + require_confirmation=True, + no_colors=True) + settings = conf.get_settings(Mock()) + assert settings.rules == conf.DEFAULT_RULES + ['test'] + + +@pytest.mark.usefixture('load_source') +class TestSettingsFromEnv(object): + def test_from_env(self, environ): + environ.update({'THEFUCK_RULES': 'bash:lisp', + 'THEFUCK_WAIT_COMMAND': '55', + 'THEFUCK_REQUIRE_CONFIRMATION': 'true', + 'THEFUCK_NO_COLORS': 'false', + 'THEFUCK_PRIORITY': 'bash=10:lisp=wrong:vim=15'}) + settings = conf.get_settings(Mock()) + assert settings.rules == ['bash', 'lisp'] + assert settings.wait_command == 55 + assert settings.require_confirmation is True + assert settings.no_colors is False + assert settings.priority == {'bash': 10, 'vim': 15} + + def test_from_env_with_DEFAULT(self, environ): + environ.update({'THEFUCK_RULES': 'DEFAULT_RULES:bash:lisp'}) + settings = conf.get_settings(Mock()) + assert settings.rules == conf.DEFAULT_RULES + ['bash', 'lisp'] + + +class TestInitializeSettingsFile(object): + def test_ignore_if_exists(self): + settings_path_mock = Mock(is_file=Mock(return_value=True), open=Mock()) + user_dir_mock = Mock(joinpath=Mock(return_value=settings_path_mock)) + conf.initialize_settings_file(user_dir_mock) + assert settings_path_mock.is_file.call_count == 1 + assert not settings_path_mock.open.called + + def test_create_if_doesnt_exists(self): + settings_file = six.StringIO() + settings_path_mock = Mock( + is_file=Mock(return_value=False), + open=Mock(return_value=Mock( + __exit__=lambda *args: None, __enter__=lambda *args: settings_file))) + user_dir_mock = Mock(joinpath=Mock(return_value=settings_path_mock)) + conf.initialize_settings_file(user_dir_mock) + settings_file_contents = settings_file.getvalue() + assert settings_path_mock.is_file.call_count == 1 + assert settings_path_mock.open.call_count == 1 + assert conf.SETTINGS_HEADER in settings_file_contents + for setting in conf.DEFAULT_SETTINGS.items(): + assert '# {} = {}\n'.format(*setting) in settings_file_contents + settings_file.close() diff --git a/tests/test_main.py b/tests/test_main.py index 47bbe79..fe82cfa 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,103 +1,173 @@ +import pytest from subprocess import PIPE from pathlib import PosixPath, Path -from mock import patch, Mock -from thefuck import main +from mock import Mock +from thefuck import main, conf, types +from tests.utils import Rule, Command -def test_get_settings(): - with patch('thefuck.main.load_source', return_value=Mock(rules=['bash'])): - assert main.get_settings(Path('/')).rules == ['bash'] - with patch('thefuck.main.load_source', return_value=Mock(spec=[])): - assert main.get_settings(Path('/')).rules is None - - -def test_is_rule_enabled(): - assert main.is_rule_enabled(Mock(rules=None), Path('bash.py')) - assert main.is_rule_enabled(Mock(rules=['bash']), Path('bash.py')) - assert not main.is_rule_enabled(Mock(rules=['bash']), Path('lisp.py')) - - -def test_load_rule(): +def test_load_rule(mocker): match = object() get_new_command = object() - with patch('thefuck.main.load_source', - return_value=Mock( - match=match, - get_new_command=get_new_command)) as load_source: - assert main.load_rule(Path('/rules/bash.py')) == main.Rule('bash', match, get_new_command) - load_source.assert_called_once_with('bash', '/rules/bash.py') + load_source = mocker.patch( + 'thefuck.main.load_source', + return_value=Mock(match=match, + get_new_command=get_new_command, + enabled_by_default=True, + priority=900)) + assert main.load_rule(Path('/rules/bash.py')) \ + == Rule('bash', match, get_new_command, priority=900) + load_source.assert_called_once_with('bash', '/rules/bash.py') -def test_get_rules(): - with patch('thefuck.main.Path.glob') as glob, \ - patch('thefuck.main.load_source', - lambda x, _: Mock(match=x, get_new_command=x)): +class TestGetRules(object): + @pytest.fixture(autouse=True) + def glob(self, mocker): + return mocker.patch('thefuck.main.Path.glob', return_value=[]) + + def _compare_names(self, rules, names): + return [r.name for r in rules] == names + + @pytest.mark.parametrize('conf_rules, rules', [ + (conf.DEFAULT_RULES, ['bash', 'lisp', 'bash', 'lisp']), + (types.RulesNamesList(['bash']), ['bash', 'bash'])]) + def test_get(self, monkeypatch, glob, conf_rules, rules): glob.return_value = [PosixPath('bash.py'), PosixPath('lisp.py')] - assert main.get_rules( - Path('~'), - Mock(rules=None)) == [main.Rule('bash', 'bash', 'bash'), - main.Rule('lisp', 'lisp', 'lisp'), - main.Rule('bash', 'bash', 'bash'), - main.Rule('lisp', 'lisp', 'lisp')] - assert main.get_rules( - Path('~'), - Mock(rules=['bash'])) == [main.Rule('bash', 'bash', 'bash'), - main.Rule('bash', 'bash', 'bash')] + monkeypatch.setattr('thefuck.main.load_source', + lambda x, _: Rule(x)) + assert self._compare_names( + main.get_rules(Path('~'), Mock(rules=conf_rules, priority={})), + rules) + + @pytest.mark.parametrize('priority, unordered, ordered', [ + ({}, + [Rule('bash', priority=100), Rule('python', priority=5)], + ['python', 'bash']), + ({}, + [Rule('lisp', priority=9999), Rule('c', priority=conf.DEFAULT_PRIORITY)], + ['c', 'lisp']), + ({'python': 9999}, + [Rule('bash', priority=100), Rule('python', priority=5)], + ['bash', 'python'])]) + def test_ordered_by_priority(self, monkeypatch, priority, unordered, ordered): + monkeypatch.setattr('thefuck.main._get_loaded_rules', + lambda *_: unordered) + assert self._compare_names( + main.get_rules(Path('~'), Mock(priority=priority)), + ordered) -def test_get_command(): - with patch('thefuck.main.Popen') as Popen, \ - patch('thefuck.main.os.environ', - new_callable=lambda: {}), \ - patch('thefuck.main.wait_output', - return_value=True): +class TestGetCommand(object): + @pytest.fixture(autouse=True) + def Popen(self, monkeypatch): + Popen = Mock() Popen.return_value.stdout.read.return_value = b'stdout' Popen.return_value.stderr.read.return_value = b'stderr' - assert main.get_command(Mock(), ['thefuck', 'apt-get', - 'search', 'vim']) \ - == main.Command('apt-get search vim', 'stdout', 'stderr') + monkeypatch.setattr('thefuck.main.Popen', Popen) + return Popen + + @pytest.fixture(autouse=True) + def prepare(self, monkeypatch): + monkeypatch.setattr('thefuck.main.os.environ', {}) + monkeypatch.setattr('thefuck.main.wait_output', lambda *_: True) + + @pytest.fixture(autouse=True) + def generic_shell(self, monkeypatch): + monkeypatch.setattr('thefuck.shells.from_shell', lambda x: x) + monkeypatch.setattr('thefuck.shells.to_shell', lambda x: x) + + def test_get_command_calls(self, Popen): + assert main.get_command(Mock(), + ['thefuck', 'apt-get', 'search', 'vim']) \ + == Command('apt-get search vim', 'stdout', 'stderr') Popen.assert_called_once_with('apt-get search vim', shell=True, stdout=PIPE, stderr=PIPE, env={'LANG': 'C'}) - assert main.get_command(Mock(), ['']) is None + + @pytest.mark.parametrize('args, result', [ + (['thefuck', 'ls', '-la'], 'ls -la'), + (['thefuck', 'ls'], 'ls')]) + def test_get_command_script(self, args, result): + if result: + assert main.get_command(Mock(), args).script == result + else: + assert main.get_command(Mock(), args) is None -def test_get_matched_rule(capsys): - rules = [main.Rule('', lambda x, _: x.script == 'cd ..', None), - main.Rule('', lambda *_: False, None), - main.Rule('rule', Mock(side_effect=OSError('Denied')), None)] - assert main.get_matched_rule(main.Command('ls', '', ''), - rules, Mock(no_colors=True)) is None - assert main.get_matched_rule(main.Command('cd ..', '', ''), - rules, Mock(no_colors=True)) == rules[0] - assert capsys.readouterr()[1].split('\n')[0]\ - == '[WARN] Rule rule:' +class TestGetMatchedRule(object): + def test_no_match(self): + assert main.get_matched_rule( + Command('ls'), [Rule('', lambda *_: False)], + Mock(no_colors=True)) is None + + def test_match(self): + rule = Rule('', lambda x, _: x.script == 'cd ..') + assert main.get_matched_rule( + Command('cd ..'), [rule], Mock(no_colors=True)) == rule + + def test_when_rule_failed(self, capsys): + main.get_matched_rule( + Command('ls'), [Rule('test', Mock(side_effect=OSError('Denied')))], + Mock(no_colors=True)) + assert capsys.readouterr()[1].split('\n')[0] == '[WARN] Rule test:' -def test_run_rule(capsys): - with patch('thefuck.main.confirm', return_value=True): - main.run_rule(main.Rule('', None, lambda *_: 'new-command'), - None, None) +class TestRunRule(object): + @pytest.fixture(autouse=True) + def confirm(self, mocker): + return mocker.patch('thefuck.main.confirm', return_value=True) + + def test_run_rule(self, capsys): + main.run_rule(Rule(get_new_command=lambda *_: 'new-command'), + Command(), None) assert capsys.readouterr() == ('new-command\n', '') - with patch('thefuck.main.confirm', return_value=False): - main.run_rule(main.Rule('', None, lambda *_: 'new-command'), - None, None) + + def test_run_rule_with_side_effect(self, capsys): + side_effect = Mock() + settings = Mock() + command = Command() + main.run_rule(Rule(get_new_command=lambda *_: 'new-command', + side_effect=side_effect), + command, settings) + assert capsys.readouterr() == ('new-command\n', '') + side_effect.assert_called_once_with(command, settings) + + def test_when_not_comfirmed(self, capsys, confirm): + confirm.return_value = False + main.run_rule(Rule(get_new_command=lambda *_: 'new-command'), + Command(), None) assert capsys.readouterr() == ('', '') -def test_confirm(capsys): - # When confirmation not required: - assert main.confirm('command', Mock(require_confirmation=False)) - assert capsys.readouterr() == ('', 'command\n') - # When confirmation required and confirmed: - with patch('thefuck.main.sys.stdin.read', return_value='\n'): - assert main.confirm('command', Mock(require_confirmation=True, - no_colors=True)) +class TestConfirm(object): + @pytest.fixture + def stdin(self, mocker): + return mocker.patch('sys.stdin.read', return_value='\n') + + def test_when_not_required(self, capsys): + assert main.confirm('command', None, Mock(require_confirmation=False)) + assert capsys.readouterr() == ('', 'command\n') + + def test_with_side_effect_and_without_confirmation(self, capsys): + assert main.confirm('command', Mock(), Mock(require_confirmation=False)) + assert capsys.readouterr() == ('', 'command*\n') + + # `stdin` fixture should be applied after `capsys` + def test_when_confirmation_required_and_confirmed(self, capsys, stdin): + assert main.confirm('command', None, Mock(require_confirmation=True, + no_colors=True)) assert capsys.readouterr() == ('', 'command [enter/ctrl+c]') - # When confirmation required and ctrl+c: - with patch('thefuck.main.sys.stdin.read', side_effect=KeyboardInterrupt): - assert not main.confirm('command', Mock(require_confirmation=True, - no_colors=True)) + + # `stdin` fixture should be applied after `capsys` + def test_when_confirmation_required_and_confirmed_with_side_effect(self, capsys, stdin): + assert main.confirm('command', Mock(), Mock(require_confirmation=True, + no_colors=True)) + assert capsys.readouterr() == ('', 'command* [enter/ctrl+c]') + + def test_when_confirmation_required_and_aborted(self, capsys, stdin): + stdin.side_effect = KeyboardInterrupt + assert not main.confirm('command', None, Mock(require_confirmation=True, + no_colors=True)) assert capsys.readouterr() == ('', 'command [enter/ctrl+c]Aborted\n') diff --git a/tests/test_shells.py b/tests/test_shells.py new file mode 100644 index 0000000..449496c --- /dev/null +++ b/tests/test_shells.py @@ -0,0 +1,78 @@ +import pytest +from thefuck import shells + + +@pytest.fixture +def builtins_open(mocker): + return mocker.patch('six.moves.builtins.open') + + +@pytest.fixture +def isfile(mocker): + return mocker.patch('os.path.isfile', return_value=True) + + +class TestGeneric(object): + def test_from_shell(self): + assert shells.Generic().from_shell('pwd') == 'pwd' + + def test_to_shell(self): + assert shells.Generic().to_shell('pwd') == 'pwd' + + def test_put_to_history(self, builtins_open): + assert shells.Generic().put_to_history('ls') is None + assert builtins_open.call_count == 0 + + +@pytest.mark.usefixtures('isfile') +class TestBash(object): + @pytest.fixture(autouse=True) + def Popen(self, mocker): + mock = mocker.patch('thefuck.shells.Popen') + mock.return_value.stdout.read.return_value = ( + b'alias l=\'ls -CF\'\n' + b'alias la=\'ls -A\'\n' + b'alias ll=\'ls -alF\'') + return mock + + @pytest.mark.parametrize('before, after', [ + ('pwd', 'pwd'), + ('ll', 'ls -alF')]) + def test_from_shell(self, before, after): + assert shells.Bash().from_shell(before) == after + + def test_to_shell(self): + assert shells.Bash().to_shell('pwd') == 'pwd' + + def test_put_to_history(self, builtins_open): + shells.Bash().put_to_history('ls') + builtins_open.return_value.__enter__.return_value. \ + write.assert_called_once_with('ls\n') + + +@pytest.mark.usefixtures('isfile') +class TestZsh(object): + @pytest.fixture(autouse=True) + def Popen(self, mocker): + mock = mocker.patch('thefuck.shells.Popen') + mock.return_value.stdout.read.return_value = ( + b'l=\'ls -CF\'\n' + b'la=\'ls -A\'\n' + b'll=\'ls -alF\'') + return mock + + @pytest.mark.parametrize('before, after', [ + ('pwd', 'pwd'), + ('ll', 'ls -alF')]) + def test_from_shell(self, before, after): + assert shells.Zsh().from_shell(before) == after + + def test_to_shell(self): + assert shells.Zsh().to_shell('pwd') == 'pwd' + + def test_put_to_history(self, builtins_open, mocker): + mocker.patch('thefuck.shells.time', + return_value=1430707243.3517463) + shells.Zsh().put_to_history('ls') + builtins_open.return_value.__enter__.return_value. \ + write.assert_called_once_with(': 1430707243:0;ls\n') diff --git a/tests/test_types.py b/tests/test_types.py new file mode 100644 index 0000000..9c587ea --- /dev/null +++ b/tests/test_types.py @@ -0,0 +1,16 @@ +from thefuck.types import RulesNamesList, Settings +from tests.utils import Rule + + +def test_rules_names_list(): + assert RulesNamesList(['bash', 'lisp']) == ['bash', 'lisp'] + assert RulesNamesList(['bash', 'lisp']) == RulesNamesList(['bash', 'lisp']) + assert Rule('lisp') in RulesNamesList(['lisp']) + assert Rule('bash') not in RulesNamesList(['lisp']) + + +def test_update_settings(): + settings = Settings({'key': 'val'}) + new_settings = settings.update(key='new-val') + assert new_settings.key == 'new-val' + assert settings.key == 'val' diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..24d6b19 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,26 @@ +import pytest +from mock import Mock +from thefuck.utils import sudo_support, wrap_settings +from thefuck.types import Settings +from tests.utils import Command + + +@pytest.mark.parametrize('override, old, new', [ + ({'key': 'val'}, {}, {'key': 'val'}), + ({'key': 'new-val'}, {'key': 'val'}, {'key': 'new-val'})]) +def test_wrap_settings(override, old, new): + fn = lambda _, settings: settings + assert wrap_settings(override)(fn)(None, Settings(old)) == new + + +@pytest.mark.parametrize('return_value, command, called, result', [ + ('ls -lah', 'sudo ls', 'ls', 'sudo ls -lah'), + ('ls -lah', 'ls', 'ls', 'ls -lah'), + (True, 'sudo ls', 'ls', True), + (True, 'ls', 'ls', True), + (False, 'sudo ls', 'ls', False), + (False, 'ls', 'ls', False)]) +def test_sudo_support(return_value, command, called, result): + fn = Mock(return_value=return_value, __name__='') + assert sudo_support(fn)(Command(command), None) == result + fn.assert_called_once_with(Command(called), None) diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..4641971 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,16 @@ +from thefuck import types +from thefuck.conf import DEFAULT_PRIORITY + + +def Command(script='', stdout='', stderr=''): + return types.Command(script, stdout, stderr) + + +def Rule(name='', match=lambda *_: True, + get_new_command=lambda *_: '', + enabled_by_default=True, + side_effect=None, + priority=DEFAULT_PRIORITY): + return types.Rule(name, match, get_new_command, + enabled_by_default, side_effect, + priority) diff --git a/thefuck/conf.py b/thefuck/conf.py new file mode 100644 index 0000000..916d216 --- /dev/null +++ b/thefuck/conf.py @@ -0,0 +1,132 @@ +from copy import copy +from imp import load_source +import os +import sys +from six import text_type +from . import logs, types + + +class _DefaultRulesNames(types.RulesNamesList): + def __add__(self, items): + return _DefaultRulesNames(list(self) + items) + + def __contains__(self, item): + return item.enabled_by_default or \ + super(_DefaultRulesNames, self).__contains__(item) + + def __eq__(self, other): + if isinstance(other, _DefaultRulesNames): + return super(_DefaultRulesNames, self).__eq__(other) + else: + return False + + +DEFAULT_RULES = _DefaultRulesNames([]) +DEFAULT_PRIORITY = 1000 + + +DEFAULT_SETTINGS = {'rules': DEFAULT_RULES, + 'wait_command': 3, + 'require_confirmation': False, + 'no_colors': False, + 'priority': {}} + +ENV_TO_ATTR = {'THEFUCK_RULES': 'rules', + 'THEFUCK_WAIT_COMMAND': 'wait_command', + 'THEFUCK_REQUIRE_CONFIRMATION': 'require_confirmation', + 'THEFUCK_NO_COLORS': 'no_colors', + 'THEFUCK_PRIORITY': 'priority'} + + +SETTINGS_HEADER = u"""# ~/.thefuck/settings.py: The Fuck settings file +# +# The rules are defined as in the example bellow: +# +# rules = ['cd_parent', 'git_push', 'python_command', 'sudo'] +# +# The default values are as follows. Uncomment and change to fit your needs. +# See https://github.com/nvbn/thefuck#settings for more information. +# + +""" + + +def _settings_from_file(user_dir): + """Loads settings from file.""" + settings = load_source('settings', + text_type(user_dir.joinpath('settings.py'))) + return {key: getattr(settings, key) + for key in DEFAULT_SETTINGS.keys() + if hasattr(settings, key)} + + +def _rules_from_env(val): + """Transforms rules list from env-string to python.""" + val = val.split(':') + if 'DEFAULT_RULES' in val: + val = DEFAULT_RULES + [rule for rule in val if rule != 'DEFAULT_RULES'] + return val + + +def _priority_from_env(val): + """Gets priority pairs from env.""" + for part in val.split(':'): + try: + rule, priority = part.split('=') + yield rule, int(priority) + except ValueError: + continue + + +def _val_from_env(env, attr): + """Transforms env-strings to python.""" + val = os.environ[env] + if attr == 'rules': + return _rules_from_env(val) + elif attr == 'priority': + return dict(_priority_from_env(val)) + elif attr == 'wait_command': + return int(val) + elif attr in ('require_confirmation', 'no_colors'): + return val.lower() == 'true' + else: + return val + + +def _settings_from_env(): + """Loads settings from env.""" + return {attr: _val_from_env(env, attr) + for env, attr in ENV_TO_ATTR.items() + if env in os.environ} + + +def get_settings(user_dir): + """Returns settings filled with values from `settings.py` and env.""" + conf = copy(DEFAULT_SETTINGS) + try: + conf.update(_settings_from_file(user_dir)) + except Exception: + logs.exception("Can't load settings from file", + sys.exc_info(), + types.Settings(conf)) + + try: + conf.update(_settings_from_env()) + except Exception: + logs.exception("Can't load settings from env", + sys.exc_info(), + types.Settings(conf)) + + if not isinstance(conf['rules'], types.RulesNamesList): + conf['rules'] = types.RulesNamesList(conf['rules']) + + return types.Settings(conf) + + +def initialize_settings_file(user_dir): + settings_path = user_dir.joinpath('settings.py') + if not settings_path.is_file(): + with settings_path.open(mode='w') as settings_file: + settings_file.write(SETTINGS_HEADER) + for setting in DEFAULT_SETTINGS.items(): + settings_file.write(u'# {} = {}\n'.format(*setting)) diff --git a/thefuck/logs.py b/thefuck/logs.py index 9bfb02b..3d765f6 100644 --- a/thefuck/logs.py +++ b/thefuck/logs.py @@ -11,28 +11,35 @@ def color(color_, settings): return color_ -def rule_failed(rule, exc_info, settings): +def exception(title, exc_info, settings): sys.stderr.write( - u'{warn}[WARN] Rule {name}:{reset}\n{trace}' + u'{warn}[WARN] {title}:{reset}\n{trace}' u'{warn}----------------------------{reset}\n\n'.format( warn=color(colorama.Back.RED + colorama.Fore.WHITE + colorama.Style.BRIGHT, settings), reset=color(colorama.Style.RESET_ALL, settings), - name=rule.name, + title=title, trace=''.join(format_exception(*exc_info)))) -def show_command(new_command, settings): - sys.stderr.write('{bold}{command}{reset}\n'.format( +def rule_failed(rule, exc_info, settings): + exception('Rule {}'.format(rule.name), exc_info, settings) + + +def show_command(new_command, side_effect, settings): + sys.stderr.write('{bold}{command}{side_effect}{reset}\n'.format( command=new_command, + side_effect='*' if side_effect else '', bold=color(colorama.Style.BRIGHT, settings), reset=color(colorama.Style.RESET_ALL, settings))) -def confirm_command(new_command, settings): +def confirm_command(new_command, side_effect, settings): sys.stderr.write( - '{bold}{command}{reset} [{green}enter{reset}/{red}ctrl+c{reset}]'.format( + '{bold}{command}{side_effect}{reset} ' + '[{green}enter{reset}/{red}ctrl+c{reset}]'.format( command=new_command, + side_effect='*' if side_effect else '', bold=color(colorama.Style.BRIGHT, settings), green=color(colorama.Fore.GREEN, settings), red=color(colorama.Fore.RED, settings), diff --git a/thefuck/main.py b/thefuck/main.py index 4975e7b..5772d4e 100644 --- a/thefuck/main.py +++ b/thefuck/main.py @@ -1,4 +1,3 @@ -from collections import namedtuple from imp import load_source from pathlib import Path from os.path import expanduser @@ -7,11 +6,8 @@ import os import sys from psutil import Process, TimeoutExpired import colorama -from thefuck import logs - - -Command = namedtuple('Command', ('script', 'stdout', 'stderr')) -Rule = namedtuple('Rule', ('name', 'match', 'get_new_command')) +import six +from . import logs, conf, types, shells def setup_user_dir(): @@ -20,44 +16,38 @@ def setup_user_dir(): rules_dir = user_dir.joinpath('rules') if not rules_dir.is_dir(): rules_dir.mkdir(parents=True) - user_dir.joinpath('settings.py').touch() + conf.initialize_settings_file(user_dir) return user_dir -def get_settings(user_dir): - """Returns prepared settings module.""" - settings = load_source('settings', - str(user_dir.joinpath('settings.py'))) - settings.__dict__.setdefault('rules', None) - settings.__dict__.setdefault('wait_command', 3) - settings.__dict__.setdefault('require_confirmation', False) - settings.__dict__.setdefault('no_colors', False) - return settings - - -def is_rule_enabled(settings, rule): - """Returns `True` when rule mentioned in `rules` or `rules` - isn't defined. - - """ - return settings.rules is None or rule.name[:-3] in settings.rules - - def load_rule(rule): """Imports rule module and returns it.""" rule_module = load_source(rule.name[:-3], str(rule)) - return Rule(rule.name[:-3], rule_module.match, - rule_module.get_new_command) + return types.Rule(rule.name[:-3], rule_module.match, + rule_module.get_new_command, + getattr(rule_module, 'enabled_by_default', True), + getattr(rule_module, 'side_effect', None), + getattr(rule_module, 'priority', conf.DEFAULT_PRIORITY)) + + +def _get_loaded_rules(rules, settings): + """Yields all available rules.""" + for rule in rules: + if rule.name != '__init__.py': + loaded_rule = load_rule(rule) + if loaded_rule in settings.rules: + yield loaded_rule def get_rules(user_dir, settings): """Returns all enabled rules.""" - bundled = Path(__file__).parent\ - .joinpath('rules')\ - .glob('*.py') + bundled = Path(__file__).parent \ + .joinpath('rules') \ + .glob('*.py') user = user_dir.joinpath('rules').glob('*.py') - return [load_rule(rule) for rule in sorted(list(bundled)) + list(user) - if rule.name != '__init__.py' and is_rule_enabled(settings, rule)] + rules = _get_loaded_rules(sorted(bundled) + sorted(user), settings) + return sorted(rules, key=lambda rule: settings.priority.get( + rule.name, rule.priority)) def wait_output(settings, popen): @@ -80,7 +70,7 @@ def wait_output(settings, popen): def get_command(settings, args): """Creates command from `args` and executes it.""" - if sys.version_info[0] < 3: + if six.PY2: script = ' '.join(arg.decode('utf-8') for arg in args[1:]) else: script = ' '.join(args[1:]) @@ -88,11 +78,12 @@ def get_command(settings, args): if not script: return + script = shells.from_shell(script) result = Popen(script, shell=True, stdout=PIPE, stderr=PIPE, env=dict(os.environ, LANG='C')) if wait_output(settings, result): - return Command(script, result.stdout.read().decode('utf-8'), - result.stderr.read().decode('utf-8')) + return types.Command(script, result.stdout.read().decode('utf-8'), + result.stderr.read().decode('utf-8')) def get_matched_rule(command, rules, settings): @@ -105,13 +96,13 @@ def get_matched_rule(command, rules, settings): logs.rule_failed(rule, sys.exc_info(), settings) -def confirm(new_command, settings): +def confirm(new_command, side_effect, settings): """Returns `True` when running of new command confirmed.""" if not settings.require_confirmation: - logs.show_command(new_command, settings) + logs.show_command(new_command, side_effect, settings) return True - logs.confirm_command(new_command, settings) + logs.confirm_command(new_command, side_effect, settings) try: sys.stdin.read(1) return True @@ -122,27 +113,21 @@ def confirm(new_command, settings): def run_rule(rule, command, settings): """Runs command from rule for passed command.""" - new_command = rule.get_new_command(command, settings) - if confirm(new_command, settings): + new_command = shells.to_shell(rule.get_new_command(command, settings)) + if confirm(new_command, rule.side_effect, settings): + if rule.side_effect: + rule.side_effect(command, settings) + shells.put_to_history(new_command) print(new_command) -def is_second_run(command): - """Is it the second run of `fuck`?""" - return command.script.startswith('fuck') - - def main(): colorama.init() user_dir = setup_user_dir() - settings = get_settings(user_dir) + settings = conf.get_settings(user_dir) command = get_command(settings, sys.argv) if command: - if is_second_run(command): - logs.failed("Can't fuck twice", settings) - return - rules = get_rules(user_dir, settings) matched_rule = get_matched_rule(command, rules, settings) if matched_rule: diff --git a/thefuck/rules/apt_get.py b/thefuck/rules/apt_get.py new file mode 100644 index 0000000..4d5eca6 --- /dev/null +++ b/thefuck/rules/apt_get.py @@ -0,0 +1,23 @@ +try: + import CommandNotFound +except ImportError: + enabled_by_default = False + + +def match(command, settings): + if 'not found' in command.stderr: + try: + c = CommandNotFound.CommandNotFound() + pkgs = c.getPackages(command.script.split(" ")[0]) + name, _ = pkgs[0] + return True + except IndexError: + # IndexError is thrown when no matching package is found + return False + + +def get_new_command(command, settings): + c = CommandNotFound.CommandNotFound() + pkgs = c.getPackages(command.script.split(" ")[0]) + name, _ = pkgs[0] + return "sudo apt-get install {} && {}".format(name, command.script) diff --git a/thefuck/rules/brew_install.py b/thefuck/rules/brew_install.py new file mode 100644 index 0000000..0f33950 --- /dev/null +++ b/thefuck/rules/brew_install.py @@ -0,0 +1,43 @@ +import difflib +import os +import re +from subprocess import check_output + +import thefuck.logs + +# Formulars are base on each local system's status +brew_formulas = [] +try: + brew_path_prefix = check_output(['brew', '--prefix']).strip() + brew_formula_path = brew_path_prefix + '/Library/Formula' + + for file_name in os.listdir(brew_formula_path): + if file_name.endswith('.rb'): + brew_formulas.append(file_name.replace('.rb', '')) +except: + pass + + +def _get_similar_formulars(formula_name): + return difflib.get_close_matches(formula_name, brew_formulas, 1, 0.85) + + +def match(command, settings): + is_proper_command = ('brew install' in command.script and + 'No available formula' in command.stderr) + + has_possible_formulas = False + if is_proper_command: + formula = re.findall(r'Error: No available formula for ([a-z]+)', + command.stderr)[0] + has_possible_formulas = len(_get_similar_formulars(formula)) > 0 + + return has_possible_formulas + + +def get_new_command(command, settings): + not_exist_formula = re.findall(r'Error: No available formula for ([a-z]+)', + command.stderr)[0] + exist_formula = _get_similar_formulars(not_exist_formula)[0] + + return command.script.replace(not_exist_formula, exist_formula, 1) diff --git a/thefuck/rules/brew_unknown_command.py b/thefuck/rules/brew_unknown_command.py new file mode 100644 index 0000000..6664d8e --- /dev/null +++ b/thefuck/rules/brew_unknown_command.py @@ -0,0 +1,102 @@ +import difflib +import os +import re +import subprocess + + +BREW_CMD_PATH = '/Library/Homebrew/cmd' +TAP_PATH = '/Library/Taps' +TAP_CMD_PATH = '/%s/%s/cmd' + + +def _get_brew_path_prefix(): + """To get brew path""" + try: + return subprocess.check_output(['brew', '--prefix']).strip() + except: + return None + + +def _get_brew_commands(brew_path_prefix): + """To get brew default commands on local environment""" + brew_cmd_path = brew_path_prefix + BREW_CMD_PATH + + commands = [name.replace('.rb', '') for name in os.listdir(brew_cmd_path) + if name.endswith('.rb')] + + return commands + + +def _get_brew_tap_specific_commands(brew_path_prefix): + """To get tap's specific commands + https://github.com/Homebrew/homebrew/blob/master/Library/brew.rb#L115""" + commands = [] + brew_taps_path = brew_path_prefix + TAP_PATH + + for user in _get_directory_names_only(brew_taps_path): + taps = _get_directory_names_only(brew_taps_path + '/%s' % user) + + # Brew Taps's naming rule + # https://github.com/Homebrew/homebrew/blob/master/share/doc/homebrew/brew-tap.md#naming-conventions-and-limitations + taps = (tap for tap in taps if tap.startswith('homebrew-')) + for tap in taps: + tap_cmd_path = brew_taps_path + TAP_CMD_PATH % (user, tap) + + if os.path.isdir(tap_cmd_path): + commands += (name.replace('brew-', '').replace('.rb', '') + for name in os.listdir(tap_cmd_path) + if _is_brew_tap_cmd_naming(name)) + + return commands + + +def _is_brew_tap_cmd_naming(name): + if name.startswith('brew-') and name.endswith('.rb'): + return True + + return False + + +def _get_directory_names_only(path): + return [d for d in os.listdir(path) + if os.path.isdir(os.path.join(path, d))] + + +brew_path_prefix = _get_brew_path_prefix() + +# Failback commands for testing (Based on Homebrew 0.9.5) +brew_commands = ['info', 'home', 'options', 'install', 'uninstall', + 'search', 'list', 'update', 'upgrade', 'pin', 'unpin', + 'doctor', 'create', 'edit'] + +if brew_path_prefix: + try: + brew_commands = _get_brew_commands(brew_path_prefix) \ + + _get_brew_tap_specific_commands(brew_path_prefix) + except OSError: + pass + + +def _get_similar_commands(command): + return difflib.get_close_matches(command, brew_commands) + + +def match(command, settings): + is_proper_command = ('brew' in command.script and + 'Unknown command' in command.stderr) + + has_possible_commands = False + if is_proper_command: + broken_cmd = re.findall(r'Error: Unknown command: ([a-z]+)', + command.stderr)[0] + has_possible_commands = len(_get_similar_commands(broken_cmd)) > 0 + + return has_possible_commands + + +def get_new_command(command, settings): + broken_cmd = re.findall(r'Error: Unknown command: ([a-z]+)', + command.stderr)[0] + new_cmd = _get_similar_commands(broken_cmd)[0] + + return command.script.replace(broken_cmd, new_cmd, 1) diff --git a/thefuck/rules/cd_mkdir.py b/thefuck/rules/cd_mkdir.py new file mode 100644 index 0000000..7aa1d9d --- /dev/null +++ b/thefuck/rules/cd_mkdir.py @@ -0,0 +1,14 @@ +import re +from thefuck.utils import sudo_support + + +@sudo_support +def match(command, settings): + return (command.script.startswith('cd ') + and ('no such file or directory' in command.stderr.lower() + or 'cd: can\'t cd to' in command.stderr.lower())) + + +@sudo_support +def get_new_command(command, settings): + return re.sub(r'^cd (.*)', 'mkdir -p \\1 && cd \\1', command.script) diff --git a/thefuck/rules/composer_not_command.py b/thefuck/rules/composer_not_command.py new file mode 100644 index 0000000..930608d --- /dev/null +++ b/thefuck/rules/composer_not_command.py @@ -0,0 +1,15 @@ +import re + + +def match(command, settings): + return ('composer' in command.script + and ('did you mean this?' in command.stderr.lower() + or 'did you mean one of these?' in command.stderr.lower())) + + +def get_new_command(command, settings): + broken_cmd = re.findall(r"Command \"([^']*)\" is not defined", command.stderr)[0] + new_cmd = re.findall(r'Did you mean this\?[^\n]*\n\s*([^\n]*)', command.stderr) + if not new_cmd: + new_cmd = re.findall(r'Did you mean one of these\?[^\n]*\n\s*([^\n]*)', command.stderr) + return command.script.replace(broken_cmd, new_cmd[0].strip(), 1) \ No newline at end of file diff --git a/thefuck/rules/cp_omitting_directory.py b/thefuck/rules/cp_omitting_directory.py index 14e84c9..4dee781 100644 --- a/thefuck/rules/cp_omitting_directory.py +++ b/thefuck/rules/cp_omitting_directory.py @@ -1,10 +1,13 @@ import re +from thefuck.utils import sudo_support +@sudo_support def match(command, settings): return command.script.startswith('cp ') \ and 'cp: omitting directory' in command.stderr.lower() +@sudo_support def get_new_command(command, settings): return re.sub(r'^cp', 'cp -a', command.script) diff --git a/thefuck/rules/cpp11.py b/thefuck/rules/cpp11.py new file mode 100644 index 0000000..154abab --- /dev/null +++ b/thefuck/rules/cpp11.py @@ -0,0 +1,9 @@ +def match(command, settings): + return (('g++' in command.script or 'clang++' in command.script) and + ('This file requires compiler and library support for the ' + 'ISO C++ 2011 standard.' in command.stderr or + '-Wc++11-extensions' in command.stderr)) + + +def get_new_command(command, settings): + return command.script + ' -std=c++11' diff --git a/thefuck/rules/dry.py b/thefuck/rules/dry.py new file mode 100644 index 0000000..f0954ea --- /dev/null +++ b/thefuck/rules/dry.py @@ -0,0 +1,12 @@ +def match(command, settings): + split_command = command.script.split() + + return len(split_command) >= 2 and split_command[0] == split_command[1] + + +def get_new_command(command, settings): + return command.script[command.script.find(' ')+1:] + +# it should be rare enough to actually have to type twice the same word, so +# this rule can have a higher priority to come before things like "cd cd foo" +priority = 900 diff --git a/thefuck/rules/fix_alt_space.py b/thefuck/rules/fix_alt_space.py new file mode 100644 index 0000000..3c74ab3 --- /dev/null +++ b/thefuck/rules/fix_alt_space.py @@ -0,0 +1,15 @@ +# -*- encoding: utf-8 -*- + +import re +from thefuck.utils import sudo_support + + +@sudo_support +def match(command, settings): + return ('command not found' in command.stderr.lower() + and u' ' in command.script) + + +@sudo_support +def get_new_command(command, settings): + return re.sub(u' ', ' ', command.script) diff --git a/thefuck/rules/git_add.py b/thefuck/rules/git_add.py new file mode 100644 index 0000000..66c7f1d --- /dev/null +++ b/thefuck/rules/git_add.py @@ -0,0 +1,15 @@ +import re + + +def match(command, settings): + return ('git' in command.script + and 'did not match any file(s) known to git.' in command.stderr + and "Did you forget to 'git add'?" in command.stderr) + + +def get_new_command(command, settings): + missing_file = re.findall( + r"error: pathspec '([^']*)' " + "did not match any file\(s\) known to git.", command.stderr)[0] + + return 'git add -- {} && {}'.format(missing_file, command.script) diff --git a/thefuck/rules/git_checkout.py b/thefuck/rules/git_checkout.py new file mode 100644 index 0000000..271562b --- /dev/null +++ b/thefuck/rules/git_checkout.py @@ -0,0 +1,15 @@ +import re + + +def match(command, settings): + return ('git' in command.script + and 'did not match any file(s) known to git.' in command.stderr + and "Did you forget to 'git add'?" not in command.stderr) + + +def get_new_command(command, settings): + missing_file = re.findall( + r"error: pathspec '([^']*)' " + "did not match any file\(s\) known to git.", command.stderr)[0] + + return 'git branch {} && {}'.format(missing_file, command.script) diff --git a/thefuck/rules/has_exists_script.py b/thefuck/rules/has_exists_script.py index 4ceac48..19a7e48 100644 --- a/thefuck/rules/has_exists_script.py +++ b/thefuck/rules/has_exists_script.py @@ -1,11 +1,14 @@ import os +from thefuck.utils import sudo_support +@sudo_support def match(command, settings): return os.path.exists(command.script.split()[0]) \ and 'command not found' in command.stderr +@sudo_support def get_new_command(command, settings): return u'./{}'.format(command.script) diff --git a/thefuck/rules/lein_not_task.py b/thefuck/rules/lein_not_task.py index efc25a1..a043263 100644 --- a/thefuck/rules/lein_not_task.py +++ b/thefuck/rules/lein_not_task.py @@ -1,12 +1,15 @@ import re +from thefuck.utils import sudo_support +@sudo_support def match(command, settings): return (command.script.startswith('lein') and "is not a task. See 'lein help'" in command.stderr and 'Did you mean this?' in command.stderr) +@sudo_support def get_new_command(command, settings): broken_cmd = re.findall(r"'([^']*)' is not a task", command.stderr)[0] diff --git a/thefuck/rules/ls_lah.py b/thefuck/rules/ls_lah.py new file mode 100644 index 0000000..50fe9f5 --- /dev/null +++ b/thefuck/rules/ls_lah.py @@ -0,0 +1,11 @@ +enabled_by_default = False + + +def match(command, settings): + return 'ls' in command.script and not ('ls -' in command.script) + + +def get_new_command(command, settings): + command = command.script.split(' ') + command[0] = 'ls -lah' + return ' '.join(command) diff --git a/thefuck/rules/man_no_space.py b/thefuck/rules/man_no_space.py new file mode 100644 index 0000000..1751352 --- /dev/null +++ b/thefuck/rules/man_no_space.py @@ -0,0 +1,9 @@ +def match(command, settings): + return (command.script.startswith(u'man') + and u'command not found' in command.stderr.lower()) + + +def get_new_command(command, settings): + return u'man {}'.format(command.script[3:]) + +priority = 2000 diff --git a/thefuck/rules/mkdir_p.py b/thefuck/rules/mkdir_p.py index 896f08f..03b40ce 100644 --- a/thefuck/rules/mkdir_p.py +++ b/thefuck/rules/mkdir_p.py @@ -1,9 +1,13 @@ import re +from thefuck.utils import sudo_support + +@sudo_support def match(command, settings): return ('mkdir' in command.script and 'No such file or directory' in command.stderr) +@sudo_support def get_new_command(command, settings): return re.sub('^mkdir (.*)', 'mkdir -p \\1', command.script) diff --git a/thefuck/rules/no_command.py b/thefuck/rules/no_command.py index ce90926..1a152c9 100644 --- a/thefuck/rules/no_command.py +++ b/thefuck/rules/no_command.py @@ -1,6 +1,7 @@ from difflib import get_close_matches import os from pathlib import Path +from thefuck.utils import sudo_support def _safe(fn, fallback): @@ -17,14 +18,19 @@ def _get_all_bins(): if not _safe(exe.is_dir, True)] +@sudo_support def match(command, settings): return 'not found' in command.stderr and \ bool(get_close_matches(command.script.split(' ')[0], _get_all_bins())) +@sudo_support def get_new_command(command, settings): old_command = command.script.split(' ')[0] new_command = get_close_matches(old_command, _get_all_bins())[0] return ' '.join([new_command] + command.script.split(' ')[1:]) + + +priority = 3000 diff --git a/thefuck/rules/pacman.py b/thefuck/rules/pacman.py new file mode 100644 index 0000000..4115798 --- /dev/null +++ b/thefuck/rules/pacman.py @@ -0,0 +1,43 @@ +import subprocess +from thefuck.utils import DEVNULL + + +def __command_available(command): + try: + subprocess.check_output([command], stderr=DEVNULL) + return True + except subprocess.CalledProcessError: + # command exists but is not happy to be called without any argument + return True + except OSError: + return False + + +def __get_pkgfile(command): + try: + return subprocess.check_output( + ['pkgfile', '-b', '-v', command.script.split(" ")[0]], + universal_newlines=True, stderr=subprocess.DEVNULL + ).split() + except subprocess.CalledProcessError: + return None + + +def match(command, settings): + return 'not found' in command.stderr and __get_pkgfile(command) + + +def get_new_command(command, settings): + package = __get_pkgfile(command)[0] + + return '{} -S {} && {}'.format(pacman, package, command.script) + + +if not __command_available('pkgfile'): + enabled_by_default = False +elif __command_available('yaourt'): + pacman = 'yaourt' +elif __command_available('pacman'): + pacman = 'sudo pacman' +else: + enabled_by_default = False diff --git a/thefuck/rules/pip_unknown_command.py b/thefuck/rules/pip_unknown_command.py new file mode 100644 index 0000000..162258e --- /dev/null +++ b/thefuck/rules/pip_unknown_command.py @@ -0,0 +1,15 @@ +import re + + +def match(command, settings): + return ('pip' in command.script and + 'unknown command' in command.stderr and + 'maybe you meant' in command.stderr) + + +def get_new_command(command, settings): + broken_cmd = re.findall(r'ERROR: unknown command \"([a-z]+)\"', + command.stderr)[0] + new_cmd = re.findall(r'maybe you meant \"([a-z]+)\"', command.stderr)[0] + + return command.script.replace(broken_cmd, new_cmd, 1) diff --git a/thefuck/rules/python_command.py b/thefuck/rules/python_command.py index 507a934..f2bc8dc 100644 --- a/thefuck/rules/python_command.py +++ b/thefuck/rules/python_command.py @@ -1,7 +1,10 @@ +from thefuck.utils import sudo_support # add 'python' suffix to the command if # 1) The script does not have execute permission or # 2) is interpreted as shell script + +@sudo_support def match(command, settings): toks = command.script.split() return (len(toks) > 0 @@ -10,5 +13,6 @@ def match(command, settings): 'command not found' in command.stderr)) +@sudo_support def get_new_command(command, settings): return 'python ' + command.script diff --git a/thefuck/rules/rm_dir.py b/thefuck/rules/rm_dir.py index f9349ea..89b1d2b 100644 --- a/thefuck/rules/rm_dir.py +++ b/thefuck/rules/rm_dir.py @@ -1,9 +1,13 @@ import re +from thefuck.utils import sudo_support + +@sudo_support def match(command, settings): return ('rm' in command.script - and 'is a directory' in command.stderr) + and 'is a directory' in command.stderr.lower()) +@sudo_support def get_new_command(command, settings): return re.sub('^rm (.*)', 'rm -rf \\1', command.script) diff --git a/thefuck/rules/rm_root.py b/thefuck/rules/rm_root.py new file mode 100644 index 0000000..ed0121f --- /dev/null +++ b/thefuck/rules/rm_root.py @@ -0,0 +1,16 @@ +from thefuck.utils import sudo_support + + +enabled_by_default = False + + +@sudo_support +def match(command, settings): + return ({'rm', '/'}.issubset(command.script.split()) + and '--no-preserve-root' not in command.script + and '--no-preserve-root' in command.stderr) + + +@sudo_support +def get_new_command(command, settings): + return u'{} --no-preserve-root'.format(command.script) diff --git a/thefuck/rules/sl_ls.py b/thefuck/rules/sl_ls.py new file mode 100644 index 0000000..0b3d017 --- /dev/null +++ b/thefuck/rules/sl_ls.py @@ -0,0 +1,14 @@ +""" +This happens way too often + +When typing really fast cause I'm a 1337 H4X0R, +I often fuck up 'ls' and type 'sl'. No more! +""" + + +def match(command, settings): + return command.script == 'sl' + + +def get_new_command(command, settings): + return 'ls' diff --git a/thefuck/rules/ssh_known_hosts.py b/thefuck/rules/ssh_known_hosts.py index ab73c42..4e88969 100644 --- a/thefuck/rules/ssh_known_hosts.py +++ b/thefuck/rules/ssh_known_hosts.py @@ -22,7 +22,11 @@ def match(command, settings): return True -def remove_offending_keys(command, settings): +def get_new_command(command, settings): + return command.script + + +def side_effect(command, settings): offending = offending_pattern.findall(command.stderr) for filepath, lineno in offending: with open(filepath, 'r') as fh: @@ -30,8 +34,3 @@ def remove_offending_keys(command, settings): del lines[int(lineno) - 1] with open(filepath, 'w') as fh: fh.writelines(lines) - - -def get_new_command(command, settings): - remove_offending_keys(command, settings) - return command.script diff --git a/thefuck/rules/sudo.py b/thefuck/rules/sudo.py index 2c98579..266d268 100644 --- a/thefuck/rules/sudo.py +++ b/thefuck/rules/sudo.py @@ -6,12 +6,18 @@ patterns = ['permission denied', 'Operation not permitted', 'root privilege', 'This command has to be run under the root user.', - 'You need to be root to perform this command.'] + 'This operation requires root.', + 'You need to be root to perform this command.', + 'requested operation requires superuser privilege', + 'must be run as root', + 'must be superuser', + 'Need to be root'] def match(command, settings): for pattern in patterns: - if pattern.lower() in command.stderr.lower(): + if pattern.lower() in command.stderr.lower()\ + or pattern.lower() in command.stdout.lower(): return True return False diff --git a/thefuck/shells.py b/thefuck/shells.py new file mode 100644 index 0000000..e1203f4 --- /dev/null +++ b/thefuck/shells.py @@ -0,0 +1,118 @@ +"""Module with shell specific actions, each shell class should +implement `from_shell`, `to_shell`, `app_alias` and `put_to_history` +methods. + +""" +from collections import defaultdict +from subprocess import Popen, PIPE +from time import time +import os +from psutil import Process +from .utils import DEVNULL + + +class Generic(object): + def _get_aliases(self): + return {} + + def _expand_aliases(self, command_script): + aliases = self._get_aliases() + binary = command_script.split(' ')[0] + if binary in aliases: + return command_script.replace(binary, aliases[binary], 1) + else: + return command_script + + def from_shell(self, command_script): + """Prepares command before running in app.""" + return self._expand_aliases(command_script) + + def to_shell(self, command_script): + """Prepares command for running in shell.""" + return command_script + + def app_alias(self): + return "\nalias fuck='eval $(thefuck $(fc -ln -1))'\n" + + def _get_history_file_name(self): + return '' + + def _get_history_line(self, command_script): + return '' + + def put_to_history(self, command_script): + """Puts command script to shell history.""" + history_file_name = self._get_history_file_name() + if os.path.isfile(history_file_name): + with open(history_file_name, 'a') as history: + history.write(self._get_history_line(command_script)) + + +class Bash(Generic): + def _parse_alias(self, alias): + name, value = alias.replace('alias ', '', 1).split('=', 1) + if value[0] == value[-1] == '"' or value[0] == value[-1] == "'": + value = value[1:-1] + return name, value + + def _get_aliases(self): + proc = Popen('bash -ic alias', stdout=PIPE, stderr=DEVNULL, shell=True) + return dict( + self._parse_alias(alias) + for alias in proc.stdout.read().decode('utf-8').split('\n') + if alias and '=' in alias) + + def _get_history_file_name(self): + return os.environ.get("HISTFILE", + os.path.expanduser('~/.bash_history')) + + def _get_history_line(self, command_script): + return u'{}\n'.format(command_script) + + +class Zsh(Generic): + def _parse_alias(self, alias): + name, value = alias.split('=', 1) + if value[0] == value[-1] == '"' or value[0] == value[-1] == "'": + value = value[1:-1] + return name, value + + def _get_aliases(self): + proc = Popen('zsh -ic alias', stdout=PIPE, stderr=DEVNULL, shell=True) + return dict( + self._parse_alias(alias) + for alias in proc.stdout.read().decode('utf-8').split('\n') + if alias and '=' in alias) + + def _get_history_file_name(self): + return os.environ.get("HISTFILE", + os.path.expanduser('~/.zsh_history')) + + def _get_history_line(self, command_script): + return u': {}:0;{}\n'.format(int(time()), command_script) + + +shells = defaultdict(lambda: Generic(), { + 'bash': Bash(), + 'zsh': Zsh()}) + + +def _get_shell(): + shell = Process(os.getpid()).parent().cmdline()[0] + return shells[shell] + + +def from_shell(command): + return _get_shell().from_shell(command) + + +def to_shell(command): + return _get_shell().to_shell(command) + + +def app_alias(): + return _get_shell().app_alias() + + +def put_to_history(command): + return _get_shell().put_to_history(command) diff --git a/thefuck/types.py b/thefuck/types.py new file mode 100644 index 0000000..3ca2cf8 --- /dev/null +++ b/thefuck/types.py @@ -0,0 +1,27 @@ +from collections import namedtuple + + +Command = namedtuple('Command', ('script', 'stdout', 'stderr')) + +Rule = namedtuple('Rule', ('name', 'match', 'get_new_command', + 'enabled_by_default', 'side_effect', + 'priority')) + + +class RulesNamesList(list): + """Wrapper a top of list for storing rules names.""" + + def __contains__(self, item): + return super(RulesNamesList, self).__contains__(item.name) + + +class Settings(dict): + + def __getattr__(self, item): + return self.get(item) + + def update(self, **kwargs): + """Returns new settings with new values from `kwargs`.""" + conf = dict(self) + conf.update(kwargs) + return Settings(conf) diff --git a/thefuck/utils.py b/thefuck/utils.py index fa4ee1e..3247111 100644 --- a/thefuck/utils.py +++ b/thefuck/utils.py @@ -1,5 +1,10 @@ from functools import wraps import os +import six +from .types import Command + + +DEVNULL = open(os.devnull, 'w') def which(program): @@ -35,9 +40,25 @@ def wrap_settings(params): def decorator(fn): @wraps(fn) def wrapper(command, settings): - for key, val in params.items(): - if not hasattr(settings, key): - setattr(settings, key, val) - return fn(command, settings) + return fn(command, settings.update(**params)) return wrapper return decorator + + +def sudo_support(fn): + """Removes sudo before calling fn and adds it after.""" + @wraps(fn) + def wrapper(command, settings): + if not command.script.startswith('sudo '): + return fn(command, settings) + + result = fn(Command(command.script[5:], + command.stdout, + command.stderr), + settings) + + if result and isinstance(result, six.string_types): + return u'sudo {}'.format(result) + else: + return result + return wrapper diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..46cf1c1 --- /dev/null +++ b/tox.ini @@ -0,0 +1,6 @@ +[tox] +envlist = py27,py33,py34 + +[testenv] +deps = -rrequirements.txt +commands = py.test