diff --git a/README.md b/README.md index 07ec8bb0..d3016f9e 100644 --- a/README.md +++ b/README.md @@ -73,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 @@ -104,7 +104,7 @@ sudo pip install thefuck [Or using an OS package manager (OS X, Ubuntu, Arch).](https://github.com/nvbn/thefuck/wiki/Installation) -And add to `.bashrc` or `.bash_profile`(for OSX): +And add to the `.bashrc` or `.bash_profile`(for OSX): ```bash alias fuck='eval $(thefuck $(fc -ln -1)); history -r' @@ -137,21 +137,25 @@ 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; @@ -193,13 +197,13 @@ 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 ``` @@ -208,12 +212,13 @@ priority = 1000 # Lower first ## 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 `thefuck.conf.DEFAULT_RULES`; -* `require_confirmation` – require confirmation before running new command, by default `False`; +* `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`: @@ -222,6 +227,7 @@ rules = ['sudo', 'no_command'] require_confirmation = True wait_command = 10 no_colors = False +priority = {'sudo': 100, 'no_command': 9999} ``` Or via environment variables: @@ -229,7 +235,9 @@ 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_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: @@ -238,6 +246,7 @@ 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 e22209b6..85af9285 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 625bed64..78b43ee1 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 00000000..2a9acf13 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal = 1 diff --git a/setup.py b/setup.py index 5c39e936..c79654f0 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ from setuptools import setup, find_packages -VERSION = '1.35' +VERSION = '1.39' setup(name='thefuck', @@ -17,4 +17,5 @@ setup(name='thefuck', zip_safe=False, install_requires=['pathlib', 'psutil', 'colorama', 'six'], entry_points={'console_scripts': [ - 'thefuck = thefuck.main:main', 'thefuck-alias = thefuck.main:alias']}) + 'thefuck = thefuck.main:main', + 'thefuck-alias = thefuck.shells:app_alias']}) diff --git a/tests/rules/test_dry.py b/tests/rules/test_dry.py new file mode 100644 index 00000000..757866df --- /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_man_no_space.py b/tests/rules/test_man_no_space.py new file mode 100644 index 00000000..669ebb85 --- /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/test_conf.py b/tests/test_conf.py index c5a8333b..1fde6a67 100644 --- a/tests/test_conf.py +++ b/tests/test_conf.py @@ -14,10 +14,8 @@ def test_default(enabled, rules, result): @pytest.fixture -def load_source(monkeypatch): - mock = Mock() - monkeypatch.setattr('thefuck.conf.load_source', mock) - return mock +def load_source(mocker): + return mocker.patch('thefuck.conf.load_source') @pytest.fixture @@ -40,12 +38,14 @@ class TestSettingsFromFile(object): load_source.return_value = Mock(rules=['test'], wait_command=10, require_confirmation=True, - no_colors=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'], @@ -62,12 +62,14 @@ class TestSettingsFromEnv(object): environ.update({'THEFUCK_RULES': 'bash:lisp', 'THEFUCK_WAIT_COMMAND': '55', 'THEFUCK_REQUIRE_CONFIRMATION': 'true', - 'THEFUCK_NO_COLORS': 'false'}) + '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'}) diff --git a/tests/test_main.py b/tests/test_main.py index de750510..fe82cfaa 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -6,15 +6,15 @@ from thefuck import main, conf, types from tests.utils import Rule, Command -def test_load_rule(monkeypatch): +def test_load_rule(mocker): match = object() get_new_command = object() - load_source = Mock() - load_source.return_value = Mock(match=match, - get_new_command=get_new_command, - enabled_by_default=True, - priority=900) - monkeypatch.setattr('thefuck.main.load_source', load_source) + 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') @@ -22,10 +22,8 @@ def test_load_rule(monkeypatch): class TestGetRules(object): @pytest.fixture(autouse=True) - def glob(self, monkeypatch): - mock = Mock(return_value=[]) - monkeypatch.setattr('thefuck.main.Path.glob', mock) - return mock + 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 @@ -38,17 +36,25 @@ class TestGetRules(object): monkeypatch.setattr('thefuck.main.load_source', lambda x, _: Rule(x)) assert self._compare_names( - main.get_rules(Path('~'), Mock(rules=conf_rules)), rules) + main.get_rules(Path('~'), Mock(rules=conf_rules, priority={})), + rules) - @pytest.mark.parametrize('unordered, ordered', [ - ([Rule('bash', priority=100), Rule('python', priority=5)], + @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'])]) - def test_ordered_by_priority(self, monkeypatch, unordered, ordered): + ({}, + [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()), ordered) + assert self._compare_names( + main.get_rules(Path('~'), Mock(priority=priority)), + ordered) class TestGetCommand(object): @@ -110,10 +116,8 @@ class TestGetMatchedRule(object): class TestRunRule(object): @pytest.fixture(autouse=True) - def confirm(self, monkeypatch): - mock = Mock(return_value=True) - monkeypatch.setattr('thefuck.main.confirm', mock) - return mock + 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'), @@ -139,10 +143,8 @@ class TestRunRule(object): class TestConfirm(object): @pytest.fixture - def stdin(self, monkeypatch): - mock = Mock(return_value='\n') - monkeypatch.setattr('sys.stdin.read', mock) - return mock + 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)) diff --git a/tests/test_shells.py b/tests/test_shells.py index 5b2748e5..449496c9 100644 --- a/tests/test_shells.py +++ b/tests/test_shells.py @@ -1,20 +1,15 @@ import pytest -from mock import Mock, MagicMock from thefuck import shells @pytest.fixture -def builtins_open(monkeypatch): - mock = MagicMock() - monkeypatch.setattr('six.moves.builtins.open', mock) - return mock +def builtins_open(mocker): + return mocker.patch('six.moves.builtins.open') @pytest.fixture -def isfile(monkeypatch): - mock = Mock(return_value=True) - monkeypatch.setattr('os.path.isfile', mock) - return mock +def isfile(mocker): + return mocker.patch('os.path.isfile', return_value=True) class TestGeneric(object): @@ -32,9 +27,8 @@ class TestGeneric(object): @pytest.mark.usefixtures('isfile') class TestBash(object): @pytest.fixture(autouse=True) - def Popen(self, monkeypatch): - mock = Mock() - monkeypatch.setattr('thefuck.shells.Popen', mock) + 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' @@ -52,16 +46,15 @@ class TestBash(object): def test_put_to_history(self, builtins_open): shells.Bash().put_to_history('ls') - builtins_open.return_value.__enter__.return_value.\ + 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, monkeypatch): - mock = Mock() - monkeypatch.setattr('thefuck.shells.Popen', mock) + 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' @@ -77,9 +70,9 @@ class TestZsh(object): def test_to_shell(self): assert shells.Zsh().to_shell('pwd') == 'pwd' - def test_put_to_history(self, builtins_open, monkeypatch): - monkeypatch.setattr('thefuck.shells.time', - lambda: 1430707243.3517463) + 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') \ No newline at end of file + write.assert_called_once_with(': 1430707243:0;ls\n') diff --git a/thefuck/conf.py b/thefuck/conf.py index 80d16a15..82a4a2be 100644 --- a/thefuck/conf.py +++ b/thefuck/conf.py @@ -28,14 +28,16 @@ DEFAULT_PRIORITY = 1000 DEFAULT_SETTINGS = {'rules': DEFAULT_RULES, 'wait_command': 3, 'require_confirmation': False, - 'no_colors': False} + 'no_colors': False, + 'priority': {}} DEFAULT_FUCKUPS = {'cd..':'cd ..'} ENV_TO_ATTR = {'THEFUCK_RULES': 'rules', 'THEFUCK_WAIT_COMMAND': 'wait_command', 'THEFUCK_REQUIRE_CONFIRMATION': 'require_confirmation', - 'THEFUCK_NO_COLORS': 'no_colors'} + 'THEFUCK_NO_COLORS': 'no_colors', + 'THEFUCK_PRIORITY': 'priority'} SETTINGS_HEADER = u"""# ~/.thefuck/settings.py: The Fuck settings file @@ -68,16 +70,29 @@ def _rules_from_env(val): 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': - val = _rules_from_env(val) + return _rules_from_env(val) + elif attr == 'priority': + return dict(_priority_from_env(val)) elif attr == 'wait_command': - val = int(val) + return int(val) elif attr in ('require_confirmation', 'no_colors'): - val = val.lower() == 'true' - return val + return val.lower() == 'true' + else: + return val def _settings_from_env(): diff --git a/thefuck/main.py b/thefuck/main.py index 5db92f0f..824f3872 100644 --- a/thefuck/main.py +++ b/thefuck/main.py @@ -47,7 +47,8 @@ def get_rules(user_dir, settings): .glob('*.py') user = user_dir.joinpath('rules').glob('*.py') rules = _get_loaded_rules(sorted(bundled) + sorted(user), settings) - return sorted(rules, key=lambda rule: rule.priority) + return sorted(rules, key=lambda rule: settings.priority.get( + rule.name, rule.priority)) def wait_output(settings, popen): @@ -144,10 +145,6 @@ def run_rule(rule, command, settings): print(new_command) -def alias(): - print(shells.app_alias()) - - def main(): colorama.init() user_dir = setup_user_dir() diff --git a/thefuck/rules/cpp11.py b/thefuck/rules/cpp11.py new file mode 100644 index 00000000..154ababc --- /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 00000000..f0954ea6 --- /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/git_checkout.py b/thefuck/rules/git_checkout.py new file mode 100644 index 00000000..271562b8 --- /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/man_no_space.py b/thefuck/rules/man_no_space.py new file mode 100644 index 00000000..17513522 --- /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/no_command.py b/thefuck/rules/no_command.py index 917e1fc9..1a152c99 100644 --- a/thefuck/rules/no_command.py +++ b/thefuck/rules/no_command.py @@ -31,3 +31,6 @@ def get_new_command(command, settings): 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/shells.py b/thefuck/shells.py index 1d128cda..e1203f45 100644 --- a/thefuck/shells.py +++ b/thefuck/shells.py @@ -60,7 +60,7 @@ class Bash(Generic): return dict( self._parse_alias(alias) for alias in proc.stdout.read().decode('utf-8').split('\n') - if alias) + if alias and '=' in alias) def _get_history_file_name(self): return os.environ.get("HISTFILE", @@ -82,7 +82,7 @@ class Zsh(Generic): return dict( self._parse_alias(alias) for alias in proc.stdout.read().decode('utf-8').split('\n') - if alias) + if alias and '=' in alias) def _get_history_file_name(self): return os.environ.get("HISTFILE", diff --git a/tox.ini b/tox.ini new file mode 100644 index 00000000..46cf1c17 --- /dev/null +++ b/tox.ini @@ -0,0 +1,6 @@ +[tox] +envlist = py27,py33,py34 + +[testenv] +deps = -rrequirements.txt +commands = py.test