diff --git a/README.md b/README.md index 271659c4..acda0ba0 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,8 @@ using the matched rule and runs it. Rules enabled by default are as follows: * `composer_not_command` – fixes composer command name; * `cp_omitting_directory` – adds `-a` when you `cp` directory; * `cpp11` – adds missing `-std=c++11` to `g++` or `clang++`; +* `dirty_untar` – fixes `tar x` command that untarred in the current directory; +* `dirty_unzip` – fixes `unzip` command that unzipped in the current directory; * `django_south_ghost` – adds `--delete-ghost-migrations` to failed because ghosts django south migration; * `django_south_merge` – adds `--merge` to inconsistent django south migration; * `docker_not_command` – fixes wrong docker commands like `docker tags`; @@ -212,8 +214,8 @@ get_new_command(command: Command, settings: Settings) -> str ``` Also the rule can contain an optional function -`side_effect(command: Command, settings: Settings) -> None` and an -optional boolean `enabled_by_default`. +`side_effect(command: Command, settings: Settings) -> None` and +optional `enabled_by_default`, `requires_output` and `priority` variables. `Command` has three attributes: `script`, `stdout` and `stderr`. @@ -237,6 +239,8 @@ def side_effect(command, settings): subprocess.call('chmod 777 .', shell=True) priority = 1000 # Lower first, default is 1000 + +requires_output = True ``` [More examples of rules](https://github.com/nvbn/thefuck/tree/master/thefuck/rules), diff --git a/tests/rules/test_dirty_untar.py b/tests/rules/test_dirty_untar.py new file mode 100644 index 00000000..77d125d2 --- /dev/null +++ b/tests/rules/test_dirty_untar.py @@ -0,0 +1,62 @@ +import os +import pytest +import tarfile +from thefuck.rules.dirty_untar import match, get_new_command, side_effect +from tests.utils import Command + + +@pytest.fixture +def tar_error(tmpdir): + def fixture(filename): + path = os.path.join(str(tmpdir), filename) + + def reset(path): + with tarfile.TarFile(path, 'w') as archive: + for file in ('a', 'b', 'c'): + with open(file, 'w') as f: + f.write('*') + + archive.add(file) + + os.remove(file) + + with tarfile.TarFile(path, 'r') as archive: + archive.extractall() + + os.chdir(str(tmpdir)) + reset(path) + + assert(set(os.listdir('.')) == {filename, 'a', 'b', 'c'}) + + return fixture + +parametrize_filename = pytest.mark.parametrize('filename', [ + 'foo.tar', + 'foo.tar.gz', + 'foo.tgz']) + +parametrize_script = pytest.mark.parametrize('script, fixed', [ + ('tar xvf {}', 'mkdir -p foo && tar xvf {} -C foo'), + ('tar -xvf {}', 'mkdir -p foo && tar -xvf {} -C foo'), + ('tar --extract -f {}', 'mkdir -p foo && tar --extract -f {} -C foo')]) + +@parametrize_filename +@parametrize_script +def test_match(tar_error, filename, script, fixed): + tar_error(filename) + assert match(Command(script=script.format(filename)), None) + + +@parametrize_filename +@parametrize_script +def test_side_effect(tar_error, filename, script, fixed): + tar_error(filename) + side_effect(Command(script=script.format(filename)), None) + assert(os.listdir('.') == [filename]) + + +@parametrize_filename +@parametrize_script +def test_get_new_command(tar_error, filename, script, fixed): + tar_error(filename) + assert get_new_command(Command(script=script.format(filename)), None) == fixed.format(filename) diff --git a/tests/rules/test_dirty_unzip.py b/tests/rules/test_dirty_unzip.py new file mode 100644 index 00000000..18d34f0d --- /dev/null +++ b/tests/rules/test_dirty_unzip.py @@ -0,0 +1,45 @@ +import os +import pytest +import zipfile +from thefuck.rules.dirty_unzip import match, get_new_command, side_effect +from tests.utils import Command + + +@pytest.fixture +def zip_error(tmpdir): + path = os.path.join(str(tmpdir), 'foo.zip') + + def reset(path): + with zipfile.ZipFile(path, 'w') as archive: + archive.writestr('a', '1') + archive.writestr('b', '2') + archive.writestr('c', '3') + + archive.extractall() + + os.chdir(str(tmpdir)) + reset(path) + + assert(set(os.listdir('.')) == {'foo.zip', 'a', 'b', 'c'}) + + +@pytest.mark.parametrize('script', [ + 'unzip foo', + 'unzip foo.zip']) +def test_match(zip_error, script): + assert match(Command(script=script), None) + + +@pytest.mark.parametrize('script', [ + 'unzip foo', + 'unzip foo.zip']) +def test_side_effect(zip_error, script): + side_effect(Command(script=script), None) + assert(os.listdir('.') == ['foo.zip']) + + +@pytest.mark.parametrize('script,fixed', [ + ('unzip foo', 'unzip foo -d foo'), + ('unzip foo.zip', 'unzip foo.zip -d foo')]) +def test_get_new_command(zip_error, script, fixed): + assert get_new_command(Command(script=script), None) == fixed diff --git a/tests/test_main.py b/tests/test_main.py index 10294c3c..05858256 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -14,7 +14,8 @@ def test_load_rule(mocker): return_value=Mock(match=match, get_new_command=get_new_command, enabled_by_default=True, - priority=900)) + priority=900, + requires_output=True)) 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') @@ -152,7 +153,7 @@ class TestConfirm(object): def test_with_side_effect_and_without_confirmation(self, capsys): assert main.confirm('command', Mock(), Mock(require_confirmation=False)) - assert capsys.readouterr() == ('', 'command*\n') + assert capsys.readouterr() == ('', 'command (+side effect)\n') # `stdin` fixture should be applied after `capsys` def test_when_confirmation_required_and_confirmed(self, capsys, stdin): @@ -164,7 +165,7 @@ class TestConfirm(object): 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]') + assert capsys.readouterr() == ('', 'command (+side effect) [enter/ctrl+c]') def test_when_confirmation_required_and_aborted(self, capsys, stdin): stdin.side_effect = KeyboardInterrupt diff --git a/tests/utils.py b/tests/utils.py index 4641971d..6efeafa8 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -10,7 +10,8 @@ def Rule(name='', match=lambda *_: True, get_new_command=lambda *_: '', enabled_by_default=True, side_effect=None, - priority=DEFAULT_PRIORITY): + priority=DEFAULT_PRIORITY, + requires_output=True): return types.Rule(name, match, get_new_command, enabled_by_default, side_effect, - priority) + priority, requires_output) diff --git a/thefuck/logs.py b/thefuck/logs.py index 9cfdad4e..9df6663c 100644 --- a/thefuck/logs.py +++ b/thefuck/logs.py @@ -29,19 +29,19 @@ def rule_failed(rule, exc_info, settings): def show_command(new_command, side_effect, settings): - sys.stderr.write('{bold}{command}{side_effect}{reset}\n'.format( + sys.stderr.write('{bold}{command}{reset}{side_effect}\n'.format( command=new_command, - side_effect='*' if side_effect else '', + side_effect=' (+side effect)' if side_effect else '', bold=color(colorama.Style.BRIGHT, settings), reset=color(colorama.Style.RESET_ALL, settings))) def confirm_command(new_command, side_effect, settings): sys.stderr.write( - '{bold}{command}{side_effect}{reset} ' + '{bold}{command}{reset}{side_effect} ' '[{green}enter{reset}/{red}ctrl+c{reset}]'.format( command=new_command, - side_effect='*' if side_effect else '', + side_effect=' (+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 3668cad5..8ea1f82b 100644 --- a/thefuck/main.py +++ b/thefuck/main.py @@ -28,7 +28,8 @@ def load_rule(rule): 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)) + getattr(rule_module, 'priority', conf.DEFAULT_PRIORITY), + getattr(rule_module, 'requires_output', True)) def _get_loaded_rules(rules, settings): @@ -87,13 +88,26 @@ def get_command(settings, args): settings): result = Popen(script, shell=True, stdout=PIPE, stderr=PIPE, env=env) if wait_output(settings, result): - return types.Command(script, result.stdout.read().decode('utf-8'), - result.stderr.read().decode('utf-8')) + stdout = result.stdout.read().decode('utf-8') + stderr = result.stderr.read().decode('utf-8') + + logs.debug(u'Received stdout: {}'.format(stdout), settings) + logs.debug(u'Received stderr: {}'.format(stderr), settings) + + return types.Command(script, stdout, stderr) + else: + logs.debug(u'Execution timed out!', settings) + return types.Command(script, None, None) def get_matched_rule(command, rules, settings): """Returns first matched rule for command.""" + script_only = command.stdout is None and command.stderr is None + for rule in rules: + if script_only and rule.requires_output: + continue + try: with logs.debug_time(u'Trying rule: {};'.format(rule.name), settings): @@ -138,20 +152,16 @@ def main(): logs.debug(u'Run with settings: {}'.format(pformat(settings)), settings) command = get_command(settings, sys.argv) - if command: - logs.debug(u'Received stdout: {}'.format(command.stdout), settings) - logs.debug(u'Received stderr: {}'.format(command.stderr), settings) + rules = get_rules(user_dir, settings) + logs.debug( + u'Loaded rules: {}'.format(', '.join(rule.name for rule in rules)), + settings) - rules = get_rules(user_dir, settings) - logs.debug( - u'Loaded rules: {}'.format(', '.join(rule.name for rule in rules)), - settings) - - matched_rule = get_matched_rule(command, rules, settings) - if matched_rule: - logs.debug(u'Matched rule: {}'.format(matched_rule.name), settings) - run_rule(matched_rule, command, settings) - return + matched_rule = get_matched_rule(command, rules, settings) + if matched_rule: + logs.debug(u'Matched rule: {}'.format(matched_rule.name), settings) + run_rule(matched_rule, command, settings) + return logs.failed('No fuck given', settings) diff --git a/thefuck/rules/dirty_untar.py b/thefuck/rules/dirty_untar.py new file mode 100644 index 00000000..3b323daf --- /dev/null +++ b/thefuck/rules/dirty_untar.py @@ -0,0 +1,41 @@ +from thefuck import shells +import os +import tarfile + + +def _is_tar_extract(cmd): + if '--extract' in cmd: + return True + + cmd = cmd.split() + + return len(cmd) > 1 and 'x' in cmd[1] + + +def _tar_file(cmd): + tar_extentions = ('.tar', '.tar.Z', '.tar.bz2', '.tar.gz', '.tar.lz', + '.tar.lzma', '.tar.xz', '.taz', '.tb2', '.tbz', '.tbz2', + '.tgz', '.tlz', '.txz', '.tz') + + for c in cmd.split(): + for ext in tar_extentions: + if c.endswith(ext): + return (c, c[0:len(c)-len(ext)]) + + +def match(command, settings): + return (command.script.startswith('tar') + and '-C' not in command.script + and _is_tar_extract(command.script) + and _tar_file(command.script) is not None) + + +def get_new_command(command, settings): + return shells.and_('mkdir -p {dir}', '{cmd} -C {dir}') \ + .format(dir=_tar_file(command.script)[1], cmd=command.script) + + +def side_effect(command, settings): + with tarfile.TarFile(_tar_file(command.script)[0]) as archive: + for file in archive.getnames(): + os.remove(file) diff --git a/thefuck/rules/dirty_unzip.py b/thefuck/rules/dirty_unzip.py new file mode 100644 index 00000000..4f6e6bcc --- /dev/null +++ b/thefuck/rules/dirty_unzip.py @@ -0,0 +1,39 @@ +import os +import zipfile + + +def _is_bad_zip(file): + with zipfile.ZipFile(file, 'r') as archive: + return len(archive.namelist()) > 1 + + +def _zip_file(command): + # unzip works that way: + # unzip [-flags] file[.zip] [file(s) ...] [-x file(s) ...] + # ^ ^ files to unzip from the archive + # archive to unzip + for c in command.script.split()[1:]: + if not c.startswith('-'): + if c.endswith('.zip'): + return c + else: + return '{}.zip'.format(c) + + +def match(command, settings): + return (command.script.startswith('unzip') + and '-d' not in command.script + and _is_bad_zip(_zip_file(command))) + + +def get_new_command(command, settings): + return '{} -d {}'.format(command.script, _zip_file(command)[:-4]) + + +def side_effect(command, settings): + with zipfile.ZipFile(_zip_file(command), 'r') as archive: + for file in archive.namelist(): + os.remove(file) + + +requires_output = False diff --git a/thefuck/types.py b/thefuck/types.py index 3ca2cf85..71828edb 100644 --- a/thefuck/types.py +++ b/thefuck/types.py @@ -5,7 +5,7 @@ Command = namedtuple('Command', ('script', 'stdout', 'stderr')) Rule = namedtuple('Rule', ('name', 'match', 'get_new_command', 'enabled_by_default', 'side_effect', - 'priority')) + 'priority', 'requires_output')) class RulesNamesList(list):