diff --git a/README.md b/README.md index 97354dec..93832b9f 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,8 @@ using the matched rule and runs it. Rules enabled by default are as follows: * `man_no_space` – fixes man commands without spaces, for example `mandiff`; * `mercurial` – fixes wrong `hg` commands; * `mkdir_p` – adds `-p` when you trying to create directory without parent; +* `mvn_no_command` – adds `clean package` to `mvn`; +* `mvn_unknown_lifecycle_phase` – fixes miss spelt lifecycle phases with `mvn`; * `no_command` – fixes wrong console commands, for example `vom/vim`; * `no_such_file` – creates missing directories with `mv` and `cp` commands; * `open` – prepends `http` to address passed to `open`; diff --git a/setup.py b/setup.py index e2d6e12c..88900cf5 100755 --- a/setup.py +++ b/setup.py @@ -22,7 +22,7 @@ elif (3, 0) < version < (3, 3): VERSION = '2.8' -install_requires = ['psutil', 'colorama', 'six'] +install_requires = ['psutil', 'colorama', 'six', 'decorator'] extras_require = {':python_version<"3.4"': ['pathlib']} setup(name='thefuck', diff --git a/tests/conftest.py b/tests/conftest.py index e7f55e9c..aa232a16 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,12 @@ import pytest +from mock import Mock @pytest.fixture def no_memoize(monkeypatch): monkeypatch.setattr('thefuck.utils.memoize.disabled', True) + + +@pytest.fixture +def settings(): + return Mock(debug=False, no_colors=True) diff --git a/tests/rules/test_lein_not_task.py b/tests/rules/test_lein_not_task.py index 9eef9b44..9069fcd0 100644 --- a/tests/rules/test_lein_not_task.py +++ b/tests/rules/test_lein_not_task.py @@ -1,6 +1,6 @@ import pytest -from mock import Mock from thefuck.rules.lein_not_task import match, get_new_command +from tests.utils import Command @pytest.fixture @@ -14,10 +14,10 @@ Did you mean this? def test_match(is_not_task): - assert match(Mock(script='lein rpl', stderr=is_not_task), None) - assert not match(Mock(script='ls', stderr=is_not_task), None) + assert match(Command(script='lein rpl', stderr=is_not_task), None) + assert not match(Command(script='ls', stderr=is_not_task), None) def test_get_new_command(is_not_task): - assert get_new_command(Mock(script='lein rpl --help', stderr=is_not_task), + assert get_new_command(Command(script='lein rpl --help', stderr=is_not_task), None) == ['lein repl --help', 'lein jar --help'] diff --git a/tests/rules/test_ls_lah.py b/tests/rules/test_ls_lah.py index 66bc8365..97325258 100644 --- a/tests/rules/test_ls_lah.py +++ b/tests/rules/test_ls_lah.py @@ -1,16 +1,16 @@ -from mock import patch, Mock from thefuck.rules.ls_lah import match, get_new_command +from tests.utils import Command def test_match(): - assert match(Mock(script='ls'), None) - assert match(Mock(script='ls file.py'), None) - assert match(Mock(script='ls /opt'), None) - assert not match(Mock(script='ls -lah /opt'), None) - assert not match(Mock(script='pacman -S binutils'), None) - assert not match(Mock(script='lsof'), None) + assert match(Command(script='ls'), None) + assert match(Command(script='ls file.py'), None) + assert match(Command(script='ls /opt'), None) + assert not match(Command(script='ls -lah /opt'), None) + assert not match(Command(script='pacman -S binutils'), None) + assert not match(Command(script='lsof'), 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' + assert get_new_command(Command(script='ls file.py'), None) == 'ls -lah file.py' + assert get_new_command(Command(script='ls'), None) == 'ls -lah' diff --git a/tests/rules/test_mvn_no_command.py b/tests/rules/test_mvn_no_command.py new file mode 100644 index 00000000..5725adde --- /dev/null +++ b/tests/rules/test_mvn_no_command.py @@ -0,0 +1,40 @@ +import pytest +from thefuck.rules.mvn_no_command import match, get_new_command +from tests.utils import Command + + +@pytest.mark.parametrize('command', [ + Command(script='mvn', stdout='[ERROR] No goals have been specified for this build. You must specify a valid lifecycle phase or a goal in the format : or :[:]:. Available lifecycle phases are: validate, initialize, generate-sources, process-sources, generate-resources, process-resources, compile, process-classes, generate-test-sources, process-test-sources, generate-test-resources, process-test-resources, test-compile, process-test-classes, test, prepare-package, package, pre-integration-test, integration-test, post-integration-test, verify, install, deploy, pre-clean, clean, post-clean, pre-site, site, post-site, site-deploy. -> [Help 1]')]) +def test_match(command): + assert match(command, None) + + +@pytest.mark.parametrize('command', [ + Command(script='mvn clean', stdout=""" +[INFO] Scanning for projects...[INFO] +[INFO] ------------------------------------------------------------------------ +[INFO] Building test 0.2 +[INFO] ------------------------------------------------------------------------ +[INFO] +[INFO] --- maven-clean-plugin:2.5:clean (default-clean) @ test --- +[INFO] Deleting /home/mlk/code/test/target +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD SUCCESS +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 0.477s +[INFO] Finished at: Wed Aug 26 13:05:47 BST 2015 +[INFO] Final Memory: 6M/240M +[INFO] ------------------------------------------------------------------------ +"""), + Command(script='mvn --help'), + Command(script='mvn -v') +]) +def test_not_match(command): + assert not match(command, None) + +@pytest.mark.parametrize('command, new_command', [ + (Command(script='mvn', stdout='[ERROR] No goals have been specified for this build. You must specify a valid lifecycle phase or a goal in the format : or :[:]:. Available lifecycle phases are: validate, initialize, generate-sources, process-sources, generate-resources, process-resources, compile, process-classes, generate-test-sources, process-test-sources, generate-test-resources, process-test-resources, test-compile, process-test-classes, test, prepare-package, package, pre-integration-test, integration-test, post-integration-test, verify, install, deploy, pre-clean, clean, post-clean, pre-site, site, post-site, site-deploy. -> [Help 1]'), ['mvn clean package', 'mvn clean install']), + (Command(script='mvn -N', stdout='[ERROR] No goals have been specified for this build. You must specify a valid lifecycle phase or a goal in the format : or :[:]:. Available lifecycle phases are: validate, initialize, generate-sources, process-sources, generate-resources, process-resources, compile, process-classes, generate-test-sources, process-test-sources, generate-test-resources, process-test-resources, test-compile, process-test-classes, test, prepare-package, package, pre-integration-test, integration-test, post-integration-test, verify, install, deploy, pre-clean, clean, post-clean, pre-site, site, post-site, site-deploy. -> [Help 1]'), ['mvn -N clean package', 'mvn -N clean install'])]) +def test_get_new_command(command, new_command): + assert get_new_command(command, None) == new_command + diff --git a/tests/rules/test_mvn_unknown_lifecycle_phase.py b/tests/rules/test_mvn_unknown_lifecycle_phase.py new file mode 100644 index 00000000..421325d1 --- /dev/null +++ b/tests/rules/test_mvn_unknown_lifecycle_phase.py @@ -0,0 +1,40 @@ +import pytest +from thefuck.rules.mvn_unknown_lifecycle_phase import match, get_new_command +from tests.utils import Command + + +@pytest.mark.parametrize('command', [ + Command(script='mvn cle', stdout='[ERROR] Unknown lifecycle phase "cle". You must specify a valid lifecycle phase or a goal in the format : or :[:]:. Available lifecycle phases are: validate, initialize, generate-sources, process-sources, generate-resources, process-resources, compile, process-classes, generate-test-sources, process-test-sources, generate-test-resources, process-test-resources, test-compile, process-test-classes, test, prepare-package, package, pre-integration-test, integration-test, post-integration-test, verify, install, deploy, pre-clean, clean, post-clean, pre-site, site, post-site, site-deploy. -> [Help 1]')]) +def test_match(command): + assert match(command, None) + + +@pytest.mark.parametrize('command', [ + Command(script='mvn clean', stdout=""" +[INFO] Scanning for projects...[INFO] +[INFO] ------------------------------------------------------------------------ +[INFO] Building test 0.2 +[INFO] ------------------------------------------------------------------------ +[INFO] +[INFO] --- maven-clean-plugin:2.5:clean (default-clean) @ test --- +[INFO] Deleting /home/mlk/code/test/target +[INFO] ------------------------------------------------------------------------ +[INFO] BUILD SUCCESS +[INFO] ------------------------------------------------------------------------ +[INFO] Total time: 0.477s +[INFO] Finished at: Wed Aug 26 13:05:47 BST 2015 +[INFO] Final Memory: 6M/240M +[INFO] ------------------------------------------------------------------------ +"""), + Command(script='mvn --help'), + Command(script='mvn -v') +]) +def test_not_match(command): + assert not match(command, None) + +@pytest.mark.parametrize('command, new_command', [ + (Command(script='mvn cle', stdout='[ERROR] Unknown lifecycle phase "cle". You must specify a valid lifecycle phase or a goal in the format : or :[:]:. Available lifecycle phases are: validate, initialize, generate-sources, process-sources, generate-resources, process-resources, compile, process-classes, generate-test-sources, process-test-sources, generate-test-resources, process-test-resources, test-compile, process-test-classes, test, prepare-package, package, pre-integration-test, integration-test, post-integration-test, verify, install, deploy, pre-clean, clean, post-clean, pre-site, site, post-site, site-deploy. -> [Help 1]'), ['mvn clean', 'mvn compile']), + (Command(script='mvn claen package', stdout='[ERROR] Unknown lifecycle phase "claen". You must specify a valid lifecycle phase or a goal in the format : or :[:]:. Available lifecycle phases are: validate, initialize, generate-sources, process-sources, generate-resources, process-resources, compile, process-classes, generate-test-sources, process-test-sources, generate-test-resources, process-test-resources, test-compile, process-test-classes, test, prepare-package, package, pre-integration-test, integration-test, post-integration-test, verify, install, deploy, pre-clean, clean, post-clean, pre-site, site, post-site, site-deploy. -> [Help 1]'), ['mvn clean package'])]) +def test_get_new_command(command, new_command): + assert get_new_command(command, None) == new_command + diff --git a/tests/specific/test_sudo.py b/tests/specific/test_sudo.py index 46afa5c2..b8243322 100644 --- a/tests/specific/test_sudo.py +++ b/tests/specific/test_sudo.py @@ -13,6 +13,8 @@ from tests.utils import Command (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__='') + def fn(command, settings): + assert command == Command(called) + return return_value + assert sudo_support(fn)(Command(command), None) == result - fn.assert_called_once_with(Command(called), None) diff --git a/tests/test_corrector.py b/tests/test_corrector.py index 0496e61e..7142aa1e 100644 --- a/tests/test_corrector.py +++ b/tests/test_corrector.py @@ -3,7 +3,7 @@ from pathlib import PosixPath, Path from mock import Mock from thefuck import corrector, conf, types from tests.utils import Rule, Command, CorrectedCommand -from thefuck.corrector import make_corrected_commands, get_corrected_commands, remove_duplicates +from thefuck.corrector import make_corrected_commands, get_corrected_commands def test_load_rule(mocker): @@ -75,15 +75,6 @@ class TestGetCorrectedCommands(object): == [CorrectedCommand(script='test!', priority=100)] -def test_remove_duplicates(): - side_effect = lambda *_: None - assert set(remove_duplicates([CorrectedCommand('ls', priority=100), - CorrectedCommand('ls', priority=200), - CorrectedCommand('ls', side_effect, 300)])) \ - == {CorrectedCommand('ls', priority=100), - CorrectedCommand('ls', side_effect, 300)} - - def test_get_corrected_commands(mocker): command = Command('test', 'test', 'test') rules = [Rule(match=lambda *_: False), @@ -94,4 +85,4 @@ def test_get_corrected_commands(mocker): priority=60)] mocker.patch('thefuck.corrector.get_rules', return_value=rules) assert [cmd.script for cmd in get_corrected_commands(command, None, Mock(debug=False))] \ - == ['test@', 'test!', 'test;'] + == ['test!', 'test@', 'test;'] diff --git a/tests/test_types.py b/tests/test_types.py index 41d0f10a..c17f7a58 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -1,5 +1,6 @@ -from thefuck.types import RulesNamesList, Settings -from tests.utils import Rule +from thefuck.types import RulesNamesList, Settings, \ + SortedCorrectedCommandsSequence +from tests.utils import Rule, CorrectedCommand def test_rules_names_list(): @@ -15,3 +16,44 @@ def test_update_settings(): assert new_settings.key == 'val' assert new_settings.unset == 'unset-value' assert settings.key == 'val' + + +class TestSortedCorrectedCommandsSequence(object): + def test_realises_generator_only_on_demand(self, settings): + should_realise = False + + def gen(): + nonlocal should_realise + yield CorrectedCommand('git commit') + yield CorrectedCommand('git branch', priority=200) + assert should_realise + yield CorrectedCommand('git checkout', priority=100) + + commands = SortedCorrectedCommandsSequence(gen(), settings) + assert commands[0] == CorrectedCommand('git commit') + should_realise = True + assert commands[1] == CorrectedCommand('git checkout', priority=100) + assert commands[2] == CorrectedCommand('git branch', priority=200) + + def test_remove_duplicates(self, settings): + side_effect = lambda *_: None + seq = SortedCorrectedCommandsSequence( + iter([CorrectedCommand('ls', priority=100), + CorrectedCommand('ls', priority=200), + CorrectedCommand('ls', side_effect, 300)]), + settings) + assert set(seq) == {CorrectedCommand('ls', priority=100), + CorrectedCommand('ls', side_effect, 300)} + + +class TestCorrectedCommand(object): + + def test_equality(self): + assert CorrectedCommand('ls', None, 100) == \ + CorrectedCommand('ls', None, 200) + assert CorrectedCommand('ls', None, 100) != \ + CorrectedCommand('ls', lambda *_: _, 100) + + def test_hashable(self): + assert {CorrectedCommand('ls', None, 100), + CorrectedCommand('ls', None, 200)} == {CorrectedCommand('ls')} diff --git a/tests/test_ui.py b/tests/test_ui.py index d822ebe8..f997374c 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -4,7 +4,7 @@ from mock import Mock import pytest from itertools import islice from thefuck import ui -from thefuck.types import CorrectedCommand +from thefuck.types import CorrectedCommand, SortedCorrectedCommandsSequence @pytest.fixture @@ -58,14 +58,18 @@ def test_command_selector(): class TestSelectCommand(object): @pytest.fixture - def commands_with_side_effect(self): - return [CorrectedCommand('ls', lambda *_: None, 100), - CorrectedCommand('cd', lambda *_: None, 100)] + def commands_with_side_effect(self, settings): + return SortedCorrectedCommandsSequence( + iter([CorrectedCommand('ls', lambda *_: None, 100), + CorrectedCommand('cd', lambda *_: None, 100)]), + settings) @pytest.fixture - def commands(self): - return [CorrectedCommand('ls', None, 100), - CorrectedCommand('cd', None, 100)] + def commands(self, settings): + return SortedCorrectedCommandsSequence( + iter([CorrectedCommand('ls', None, 100), + CorrectedCommand('cd', None, 100)]), + settings) def test_without_commands(self, capsys): assert ui.select_command([], Mock(debug=False, no_color=True)) is None @@ -92,13 +96,6 @@ class TestSelectCommand(object): require_confirmation=True)) == commands[0] assert capsys.readouterr() == ('', u'\x1b[1K\rls [enter/↑/↓/ctrl+c]\n') - def test_with_confirmation_one_match(self, capsys, patch_getch, commands): - patch_getch(['\n']) - assert ui.select_command((commands[0],), - Mock(debug=False, no_color=True, - require_confirmation=True)) == commands[0] - assert capsys.readouterr() == ('', u'\x1b[1K\rls [enter/ctrl+c]\n') - def test_with_confirmation_abort(self, capsys, patch_getch, commands): patch_getch([KeyboardInterrupt]) assert ui.select_command(commands, diff --git a/tests/test_utils.py b/tests/test_utils.py index b66f4c8e..5979b204 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -2,8 +2,9 @@ import pytest from mock import Mock from thefuck.utils import wrap_settings,\ memoize, get_closest, get_all_executables, replace_argument, \ - get_all_matched_commands + get_all_matched_commands, is_app, for_app from thefuck.types import Settings +from tests.utils import Command @pytest.mark.parametrize('override, old, new', [ @@ -93,3 +94,25 @@ def test_replace_argument(args, result): 'service-status', 'service-unbind'])]) def test_get_all_matched_commands(stderr, result): assert list(get_all_matched_commands(stderr)) == result + + +@pytest.mark.usefixtures('no_memoize') +@pytest.mark.parametrize('script, names, result', [ + ('git diff', ['git', 'hub'], True), + ('hub diff', ['git', 'hub'], True), + ('hg diff', ['git', 'hub'], False)]) +def test_is_app(script, names, result): + assert is_app(Command(script), *names) == result + + +@pytest.mark.usefixtures('no_memoize') +@pytest.mark.parametrize('script, names, result', [ + ('git diff', ['git', 'hub'], True), + ('hub diff', ['git', 'hub'], True), + ('hg diff', ['git', 'hub'], False)]) +def test_for_app(script, names, result): + @for_app(*names) + def match(command, settings): + return True + + assert match(Command(script), None) == result diff --git a/thefuck/corrector.py b/thefuck/corrector.py index b7647e40..a5216c5c 100644 --- a/thefuck/corrector.py +++ b/thefuck/corrector.py @@ -1,5 +1,4 @@ -from . import conf, logs -from .utils import eager +from . import conf, types, logs from imp import load_source from pathlib import Path from thefuck.types import CorrectedCommand, Rule @@ -29,17 +28,16 @@ def get_loaded_rules(rules, settings): yield loaded_rule -@eager def get_rules(user_dir, settings): """Returns all enabled rules.""" bundled = Path(__file__).parent \ .joinpath('rules') \ .glob('*.py') user = user_dir.joinpath('rules').glob('*.py') - return get_loaded_rules(sorted(bundled) + sorted(user), settings) + return sorted(get_loaded_rules(sorted(bundled) + sorted(user), settings), + key=lambda rule: rule.priority) -@eager def get_matched_rules(command, rules, settings): """Returns first matched rule for command.""" script_only = command.stdout is None and command.stderr is None @@ -68,22 +66,8 @@ def make_corrected_commands(command, rules, settings): priority=(n + 1) * rule.priority) -def remove_duplicates(corrected_commands): - commands = {(command.script, command.side_effect): command - for command in sorted(corrected_commands, - key=lambda command: -command.priority)} - return commands.values() - - def get_corrected_commands(command, user_dir, settings): rules = get_rules(user_dir, settings) - logs.debug( - u'Loaded rules: {}'.format(', '.join(rule.name for rule in rules)), - settings) matched = get_matched_rules(command, rules, settings) - logs.debug( - u'Matched rules: {}'.format(', '.join(rule.name for rule in matched)), - settings) corrected_commands = make_corrected_commands(command, matched, settings) - return sorted(remove_duplicates(corrected_commands), - key=lambda corrected_command: corrected_command.priority) + return types.SortedCorrectedCommandsSequence(corrected_commands, settings) diff --git a/thefuck/logs.py b/thefuck/logs.py index 8c7b3a7d..ae5f1ad2 100644 --- a/thefuck/logs.py +++ b/thefuck/logs.py @@ -45,15 +45,11 @@ def show_corrected_command(corrected_command, settings): reset=color(colorama.Style.RESET_ALL, settings))) -def confirm_text(corrected_command, multiple_cmds, settings): - if multiple_cmds: - arrows = '{blue}↑{reset}/{blue}↓{reset}/' - else: - arrows = '' - +def confirm_text(corrected_command, settings): sys.stderr.write( ('{clear}{bold}{script}{reset}{side_effect} ' - '[{green}enter{reset}/' + arrows + '{red}ctrl+c{reset}]').format( + '[{green}enter{reset}/{blue}↑{reset}/{blue}↓{reset}' + '/{red}ctrl+c{reset}]').format( script=corrected_command.script, side_effect=' (+side effect)' if corrected_command.side_effect else '', clear='\033[1K\r', diff --git a/thefuck/rules/apt_get_search.py b/thefuck/rules/apt_get_search.py index 6c06ddde..4454e85f 100644 --- a/thefuck/rules/apt_get_search.py +++ b/thefuck/rules/apt_get_search.py @@ -1,6 +1,8 @@ import re +from thefuck.utils import for_app +@for_app('apt-get') def match(command, settings): return command.script.startswith('apt-get search') diff --git a/thefuck/rules/cargo_no_command.py b/thefuck/rules/cargo_no_command.py index c4f6c072..ce77fe6e 100644 --- a/thefuck/rules/cargo_no_command.py +++ b/thefuck/rules/cargo_no_command.py @@ -1,10 +1,10 @@ import re -from thefuck.utils import replace_argument +from thefuck.utils import replace_argument, for_app +@for_app('cargo') def match(command, settings): - return ('cargo' in command.script - and 'No such subcommand' in command.stderr + return ('No such subcommand' in command.stderr and 'Did you mean' in command.stderr) diff --git a/thefuck/rules/cd_correction.py b/thefuck/rules/cd_correction.py index 1567ff09..33c4fc30 100644 --- a/thefuck/rules/cd_correction.py +++ b/thefuck/rules/cd_correction.py @@ -4,6 +4,7 @@ import os from difflib import get_close_matches from thefuck.specific.sudo import sudo_support from thefuck.rules import cd_mkdir +from thefuck.utils import for_app __author__ = "mmussomele" @@ -16,6 +17,7 @@ def _get_sub_dirs(parent): @sudo_support +@for_app('cd') def match(command, settings): """Match function copied from cd_mkdir.py""" return (command.script.startswith('cd ') diff --git a/thefuck/rules/cd_mkdir.py b/thefuck/rules/cd_mkdir.py index c9ad1f7e..2262af74 100644 --- a/thefuck/rules/cd_mkdir.py +++ b/thefuck/rules/cd_mkdir.py @@ -1,13 +1,14 @@ import re from thefuck import shells +from thefuck.utils import for_app from thefuck.specific.sudo import sudo_support @sudo_support +@for_app('cd') 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())) + return (('no such file or directory' in command.stderr.lower() + or 'cd: can\'t cd to' in command.stderr.lower())) @sudo_support diff --git a/thefuck/rules/composer_not_command.py b/thefuck/rules/composer_not_command.py index 115ca180..abea7b34 100644 --- a/thefuck/rules/composer_not_command.py +++ b/thefuck/rules/composer_not_command.py @@ -1,11 +1,11 @@ import re -from thefuck.utils import replace_argument +from thefuck.utils import replace_argument, for_app +@for_app('composer') 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())) + return (('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): diff --git a/thefuck/rules/cp_omitting_directory.py b/thefuck/rules/cp_omitting_directory.py index 9a110e0c..51fa1294 100644 --- a/thefuck/rules/cp_omitting_directory.py +++ b/thefuck/rules/cp_omitting_directory.py @@ -1,12 +1,13 @@ import re from thefuck.specific.sudo import sudo_support +from thefuck.utils import for_app @sudo_support +@for_app('cp') def match(command, settings): stderr = command.stderr.lower() - return command.script.startswith('cp ') \ - and ('omitting directory' in stderr or 'is a directory' in stderr) + return 'omitting directory' in stderr or 'is a directory' in stderr @sudo_support diff --git a/thefuck/rules/cpp11.py b/thefuck/rules/cpp11.py index 154ababc..200bf4d9 100644 --- a/thefuck/rules/cpp11.py +++ b/thefuck/rules/cpp11.py @@ -1,8 +1,11 @@ +from thefuck.utils import for_app + + +@for_app(['g++', 'clang++']) 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)) + return ('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): diff --git a/thefuck/rules/dirty_untar.py b/thefuck/rules/dirty_untar.py index 25300e7b..4fdf4cf6 100644 --- a/thefuck/rules/dirty_untar.py +++ b/thefuck/rules/dirty_untar.py @@ -1,6 +1,7 @@ -from thefuck import shells import os import tarfile +from thefuck import shells +from thefuck.utils import for_app def _is_tar_extract(cmd): @@ -20,19 +21,19 @@ def _tar_file(cmd): for c in cmd.split(): for ext in tar_extensions: if c.endswith(ext): - return (c, c[0:len(c)-len(ext)]) + return (c, c[0:len(c) - len(ext)]) +@for_app('tar') def match(command, settings): - return (command.script.startswith('tar') - and '-C' not in command.script + return ('-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) + .format(dir=_tar_file(command.script)[1], cmd=command.script) def side_effect(old_cmd, command, settings): diff --git a/thefuck/rules/dirty_unzip.py b/thefuck/rules/dirty_unzip.py index 738cf82f..bd2d5945 100644 --- a/thefuck/rules/dirty_unzip.py +++ b/thefuck/rules/dirty_unzip.py @@ -1,5 +1,6 @@ import os import zipfile +from thefuck.utils import for_app def _is_bad_zip(file): @@ -20,9 +21,9 @@ def _zip_file(command): return '{}.zip'.format(c) +@for_app('unzip') def match(command, settings): - return (command.script.startswith('unzip') - and '-d' not in command.script + return ('-d' not in command.script and _is_bad_zip(_zip_file(command))) diff --git a/thefuck/rules/docker_not_command.py b/thefuck/rules/docker_not_command.py index 73cb8611..44578e39 100644 --- a/thefuck/rules/docker_not_command.py +++ b/thefuck/rules/docker_not_command.py @@ -1,14 +1,14 @@ from itertools import dropwhile, takewhile, islice import re import subprocess -from thefuck.utils import replace_command +from thefuck.utils import replace_command, for_app from thefuck.specific.sudo import sudo_support @sudo_support +@for_app('docker') def match(command, settings): - return command.script.startswith('docker') \ - and 'is not a docker command' in command.stderr + return 'is not a docker command' in command.stderr def get_docker_commands(): diff --git a/thefuck/rules/go_run.py b/thefuck/rules/go_run.py index b32c646a..b009324b 100644 --- a/thefuck/rules/go_run.py +++ b/thefuck/rules/go_run.py @@ -1,3 +1,4 @@ +from thefuck.utils import for_app # Appends .go when compiling go files # # Example: @@ -5,6 +6,7 @@ # error: go run: no go files listed +@for_app('go') def match(command, settings): return (command.script.startswith('go run ') and not command.script.endswith('.go')) diff --git a/thefuck/rules/grep_recursive.py b/thefuck/rules/grep_recursive.py index f2876fe1..94547b26 100644 --- a/thefuck/rules/grep_recursive.py +++ b/thefuck/rules/grep_recursive.py @@ -1,6 +1,9 @@ +from thefuck.utils import for_app + + +@for_app('grep') def match(command, settings): - return (command.script.startswith('grep') - and 'is a directory' in command.stderr.lower()) + return 'is a directory' in command.stderr.lower() def get_new_command(command, settings): diff --git a/thefuck/rules/gulp_not_task.py b/thefuck/rules/gulp_not_task.py index c1a548c1..853fa609 100644 --- a/thefuck/rules/gulp_not_task.py +++ b/thefuck/rules/gulp_not_task.py @@ -1,11 +1,11 @@ import re import subprocess -from thefuck.utils import replace_command +from thefuck.utils import replace_command, for_app +@for_app('gulp') def match(command, script): - return command.script.startswith('gulp')\ - and 'is not in your gulpfile' in command.stdout + return 'is not in your gulpfile' in command.stdout def get_gulp_tasks(): diff --git a/thefuck/rules/heroku_not_command.py b/thefuck/rules/heroku_not_command.py index 87360bc8..a01e1577 100644 --- a/thefuck/rules/heroku_not_command.py +++ b/thefuck/rules/heroku_not_command.py @@ -1,10 +1,10 @@ import re -from thefuck.utils import replace_command +from thefuck.utils import replace_command, for_app +@for_app('heroku') def match(command, settings): - return command.script.startswith('heroku') and \ - 'is not a heroku command' in command.stderr and \ + return 'is not a heroku command' in command.stderr and \ 'Perhaps you meant' in command.stderr diff --git a/thefuck/rules/java.py b/thefuck/rules/java.py index d2852328..f7a33c74 100644 --- a/thefuck/rules/java.py +++ b/thefuck/rules/java.py @@ -1,13 +1,16 @@ -# Fixes common java command mistake -# -# Example: -# > java foo.java -# Error: Could not find or load main class foo.java +"""Fixes common java command mistake + +Example: +> java foo.java +Error: Could not find or load main class foo.java + +""" +from thefuck.utils import for_app +@for_app('java') def match(command, settings): - return (command.script.startswith('java ') - and command.script.endswith('.java')) + return command.script.endswith('.java') def get_new_command(command, settings): diff --git a/thefuck/rules/javac.py b/thefuck/rules/javac.py index 80e6b258..be40a5e7 100644 --- a/thefuck/rules/javac.py +++ b/thefuck/rules/javac.py @@ -1,14 +1,17 @@ -# Appends .java when compiling java files -# -# Example: -# > javac foo -# error: Class names, 'foo', are only accepted if annotation -# processing is explicitly requested +"""Appends .java when compiling java files + +Example: + > javac foo + error: Class names, 'foo', are only accepted if annotation + processing is explicitly requested + +""" +from thefuck.utils import for_app +@for_app('javac') def match(command, settings): - return (command.script.startswith('javac ') - and not command.script.endswith('.java')) + return not command.script.endswith('.java') def get_new_command(command, settings): diff --git a/thefuck/rules/lein_not_task.py b/thefuck/rules/lein_not_task.py index db98c951..3849ac55 100644 --- a/thefuck/rules/lein_not_task.py +++ b/thefuck/rules/lein_not_task.py @@ -1,9 +1,10 @@ import re -from thefuck.utils import replace_command, get_all_matched_commands +from thefuck.utils import replace_command, get_all_matched_commands, for_app from thefuck.specific.sudo import sudo_support @sudo_support +@for_app('lein') def match(command, settings): return (command.script.startswith('lein') and "is not a task. See 'lein help'" in command.stderr diff --git a/thefuck/rules/ls_lah.py b/thefuck/rules/ls_lah.py index 580744bb..b8e6590b 100644 --- a/thefuck/rules/ls_lah.py +++ b/thefuck/rules/ls_lah.py @@ -1,7 +1,9 @@ +from thefuck.utils import for_app + + +@for_app('ls') def match(command, settings): - return (command.script == 'ls' - or command.script.startswith('ls ') - and 'ls -' not in command.script) + return 'ls -' not in command.script def get_new_command(command, settings): diff --git a/thefuck/rules/mercurial.py b/thefuck/rules/mercurial.py index c2e9aa6e..338629aa 100644 --- a/thefuck/rules/mercurial.py +++ b/thefuck/rules/mercurial.py @@ -1,5 +1,5 @@ import re -from thefuck.utils import get_closest +from thefuck.utils import get_closest, for_app def extract_possibilities(command): @@ -12,14 +12,12 @@ def extract_possibilities(command): return possib +@for_app('hg') def match(command, settings): - return (command.script.startswith('hg ') - and ('hg: unknown command' in command.stderr - and '(did you mean one of ' in command.stderr - or "hg: command '" in command.stderr - and "' is ambiguous:" in command.stderr - ) - ) + return ('hg: unknown command' in command.stderr + and '(did you mean one of ' in command.stderr + or "hg: command '" in command.stderr + and "' is ambiguous:" in command.stderr) def get_new_command(command, settings): diff --git a/thefuck/rules/mvn_no_command.py b/thefuck/rules/mvn_no_command.py new file mode 100644 index 00000000..7113c574 --- /dev/null +++ b/thefuck/rules/mvn_no_command.py @@ -0,0 +1,11 @@ +from thefuck.utils import for_app + + +@for_app('mvn') +def match(command, settings): + return 'No goals have been specified for this build' in command.stdout + + +def get_new_command(command, settings): + return [command.script + ' clean package', + command.script + ' clean install'] diff --git a/thefuck/rules/mvn_unknown_lifecycle_phase.py b/thefuck/rules/mvn_unknown_lifecycle_phase.py new file mode 100644 index 00000000..c4a7ee17 --- /dev/null +++ b/thefuck/rules/mvn_unknown_lifecycle_phase.py @@ -0,0 +1,32 @@ +from thefuck.utils import replace_command, for_app +from difflib import get_close_matches +import re + + +def _get_failed_lifecycle(command): + return re.search(r'\[ERROR\] Unknown lifecycle phase "(.+)"', + command.stdout) + + +def _getavailable_lifecycles(command): + return re.search( + r'Available lifecycle phases are: (.+) -> \[Help 1\]', command.stdout) + + +@for_app('mvn') +def match(command, settings): + failed_lifecycle = _get_failed_lifecycle(command) + available_lifecycles = _getavailable_lifecycles(command) + return available_lifecycles and failed_lifecycle + + +def get_new_command(command, settings): + failed_lifecycle = _get_failed_lifecycle(command) + available_lifecycles = _getavailable_lifecycles(command) + if available_lifecycles and failed_lifecycle: + selected_lifecycle = get_close_matches( + failed_lifecycle.group(1), available_lifecycles.group(1).split(", "), + 3, 0.6) + return replace_command(command, failed_lifecycle.group(1), selected_lifecycle) + else: + return [] diff --git a/thefuck/rules/open.py b/thefuck/rules/open.py index 22aaea37..6de2c963 100644 --- a/thefuck/rules/open.py +++ b/thefuck/rules/open.py @@ -5,21 +5,21 @@ # The file ~/github.com does not exist. # Perhaps you meant 'http://github.com'? # +from thefuck.utils import for_app +@for_app('open', 'xdg-open', 'gnome-open', 'kde-open') def match(command, settings): - return (command.script.startswith(('open', 'xdg-open', 'gnome-open', 'kde-open')) - and ( - '.com' in command.script - or '.net' in command.script - or '.org' in command.script - or '.ly' in command.script - or '.io' in command.script - or '.se' in command.script - or '.edu' in command.script - or '.info' in command.script - or '.me' in command.script - or 'www.' in command.script)) + return ('.com' in command.script + or '.net' in command.script + or '.org' in command.script + or '.ly' in command.script + or '.io' in command.script + or '.se' in command.script + or '.edu' in command.script + or '.info' in command.script + or '.me' in command.script + or 'www.' in command.script) def get_new_command(command, settings): diff --git a/thefuck/rules/pip_unknown_command.py b/thefuck/rules/pip_unknown_command.py index 9ae185d3..61293f80 100644 --- a/thefuck/rules/pip_unknown_command.py +++ b/thefuck/rules/pip_unknown_command.py @@ -1,7 +1,10 @@ import re -from thefuck.utils import replace_argument +from thefuck.utils import replace_argument, for_app +from thefuck.specific.sudo import sudo_support +@sudo_support +@for_app('pip') def match(command, settings): return ('pip' in command.script and 'unknown command' in command.stderr and diff --git a/thefuck/rules/python_execute.py b/thefuck/rules/python_execute.py index d4d9d266..2de751e9 100644 --- a/thefuck/rules/python_execute.py +++ b/thefuck/rules/python_execute.py @@ -3,11 +3,12 @@ # Example: # > python foo # error: python: can't open file 'foo': [Errno 2] No such file or directory +from thefuck.utils import for_app +@for_app('python') def match(command, settings): - return (command.script.startswith('python ') - and not command.script.endswith('.py')) + return not command.script.endswith('.py') def get_new_command(command, settings): diff --git a/thefuck/rules/sed_unterminated_s.py b/thefuck/rules/sed_unterminated_s.py index 80334f94..5a8ea6d3 100644 --- a/thefuck/rules/sed_unterminated_s.py +++ b/thefuck/rules/sed_unterminated_s.py @@ -1,10 +1,10 @@ import shlex -from thefuck.utils import quote +from thefuck.utils import quote, for_app +@for_app('sed') def match(command, settings): - return ('sed' in command.script - and "unterminated `s' command" in command.stderr) + return "unterminated `s' command" in command.stderr def get_new_command(command, settings): diff --git a/thefuck/rules/ssh_known_hosts.py b/thefuck/rules/ssh_known_hosts.py index 6d666fcb..c6aec195 100644 --- a/thefuck/rules/ssh_known_hosts.py +++ b/thefuck/rules/ssh_known_hosts.py @@ -1,10 +1,14 @@ import re +from thefuck.utils import for_app + +commands = ('ssh', 'scp') +@for_app(*commands) def match(command, settings): if not command.script: return False - if not command.script.startswith(('ssh', 'scp')): + if not command.script.startswith(commands): return False patterns = ( diff --git a/thefuck/rules/systemctl.py b/thefuck/rules/systemctl.py index ef8c0ca1..3edca9fc 100644 --- a/thefuck/rules/systemctl.py +++ b/thefuck/rules/systemctl.py @@ -2,15 +2,16 @@ The confusion in systemctl's param order is massive. """ from thefuck.specific.sudo import sudo_support +from thefuck.utils import for_app @sudo_support +@for_app('systemctl') def match(command, settings): # Catches 'Unknown operation 'service'.' when executing systemctl with # misordered arguments cmd = command.script.split() - return ('systemctl' in command.script and - 'Unknown operation \'' in command.stderr and + return ('Unknown operation \'' in command.stderr and len(cmd) - cmd.index('systemctl') == 3) diff --git a/thefuck/rules/tmux.py b/thefuck/rules/tmux.py index 2ba446e3..09acb578 100644 --- a/thefuck/rules/tmux.py +++ b/thefuck/rules/tmux.py @@ -1,10 +1,10 @@ -from thefuck.utils import replace_command import re +from thefuck.utils import replace_command, for_app +@for_app('tmux') def match(command, settings): - return ('tmux' in command.script - and 'ambiguous command:' in command.stderr + return ('ambiguous command:' in command.stderr and 'could be:' in command.stderr) diff --git a/thefuck/rules/tsuru_login.py b/thefuck/rules/tsuru_login.py index b71803fd..fc917159 100644 --- a/thefuck/rules/tsuru_login.py +++ b/thefuck/rules/tsuru_login.py @@ -1,9 +1,10 @@ from thefuck import shells +from thefuck.utils import for_app +@for_app('tsuru') def match(command, settings): - return (command.script.startswith('tsuru') - and 'not authenticated' in command.stderr + return ('not authenticated' in command.stderr and 'session has expired' in command.stderr) diff --git a/thefuck/rules/tsuru_not_command.py b/thefuck/rules/tsuru_not_command.py index 86b4d15f..498a4930 100644 --- a/thefuck/rules/tsuru_not_command.py +++ b/thefuck/rules/tsuru_not_command.py @@ -1,10 +1,10 @@ import re -from thefuck.utils import get_all_matched_commands, replace_command +from thefuck.utils import get_all_matched_commands, replace_command, for_app +@for_app('tsuru') def match(command, settings): - return (command.script.startswith('tsuru ') - and ' is not a tsuru command. See "tsuru help".' in command.stderr + return (' is not a tsuru command. See "tsuru help".' in command.stderr and '\nDid you mean?\n\t' in command.stderr) diff --git a/thefuck/rules/vagrant_up.py b/thefuck/rules/vagrant_up.py index 9c0a1e40..7830f301 100644 --- a/thefuck/rules/vagrant_up.py +++ b/thefuck/rules/vagrant_up.py @@ -1,8 +1,10 @@ from thefuck import shells +from thefuck.utils import for_app +@for_app('vagrant') def match(command, settings): - return command.script.startswith('vagrant ') and 'run `vagrant up`' in command.stderr.lower() + return 'run `vagrant up`' in command.stderr.lower() def get_new_command(command, settings): diff --git a/thefuck/specific/git.py b/thefuck/specific/git.py index b6cd22ff..b8420573 100644 --- a/thefuck/specific/git.py +++ b/thefuck/specific/git.py @@ -1,37 +1,32 @@ -from functools import wraps import re from shlex import split +from decorator import decorator from ..types import Command -from ..utils import quote +from ..utils import quote, is_app -def git_support(fn): +@decorator +def git_support(fn, command, settings): """Resolves git aliases and supports testing for both git and hub.""" - @wraps(fn) - def wrapper(command, settings): - # supports GitHub's `hub` command - # which is recommended to be used with `alias git=hub` - # but at this point, shell aliases have already been resolved - is_git_cmd = command.script.startswith(('git', 'hub')) + # supports GitHub's `hub` command + # which is recommended to be used with `alias git=hub` + # but at this point, shell aliases have already been resolved + if not is_app(command, 'git', 'hub'): + return False - if not is_git_cmd: - return False + # perform git aliases expansion + if 'trace: alias expansion:' in command.stderr: + search = re.search("trace: alias expansion: ([^ ]*) => ([^\n]*)", + command.stderr) + alias = search.group(1) - # perform git aliases expansion - if 'trace: alias expansion:' in command.stderr: - search = re.search("trace: alias expansion: ([^ ]*) => ([^\n]*)", - command.stderr) - alias = search.group(1) + # by default git quotes everything, for example: + # 'commit' '--amend' + # which is surprising and does not allow to easily test for + # eg. 'git commit' + expansion = ' '.join(map(quote, split(search.group(2)))) + new_script = command.script.replace(alias, expansion) - # by default git quotes everything, for example: - # 'commit' '--amend' - # which is surprising and does not allow to easily test for - # eg. 'git commit' - expansion = ' '.join(map(quote, split(search.group(2)))) - new_script = command.script.replace(alias, expansion) + command = Command._replace(command, script=new_script) - command = Command._replace(command, script=new_script) - - return fn(command, settings) - - return wrapper + return fn(command, settings) diff --git a/thefuck/specific/sudo.py b/thefuck/specific/sudo.py index 88a6cb07..8e5d30cd 100644 --- a/thefuck/specific/sudo.py +++ b/thefuck/specific/sudo.py @@ -1,24 +1,22 @@ -from functools import wraps import six +from decorator import decorator from ..types import Command -def sudo_support(fn): +@decorator +def sudo_support(fn, command, settings): """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) + if not command.script.startswith('sudo '): + return fn(command, settings) - result = fn(Command(command.script[5:], - command.stdout, - command.stderr), - 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) - elif isinstance(result, list): - return [u'sudo {}'.format(x) for x in result] - else: - return result - return wrapper \ No newline at end of file + if result and isinstance(result, six.string_types): + return u'sudo {}'.format(result) + elif isinstance(result, list): + return [u'sudo {}'.format(x) for x in result] + else: + return result diff --git a/thefuck/types.py b/thefuck/types.py index 2e5b41af..69b174ea 100644 --- a/thefuck/types.py +++ b/thefuck/types.py @@ -1,14 +1,34 @@ from collections import namedtuple - +from traceback import format_stack +from .logs import debug Command = namedtuple('Command', ('script', 'stdout', 'stderr')) -CorrectedCommand = namedtuple('CorrectedCommand', ('script', 'side_effect', 'priority')) - Rule = namedtuple('Rule', ('name', 'match', 'get_new_command', 'enabled_by_default', 'side_effect', 'priority', 'requires_output')) +class CorrectedCommand(object): + def __init__(self, script, side_effect, priority): + self.script = script + self.side_effect = side_effect + self.priority = priority + + def __eq__(self, other): + """Ignores `priority` field.""" + if isinstance(other, CorrectedCommand): + return (other.script, other.side_effect) ==\ + (self.script, self.side_effect) + else: + return False + + def __hash__(self): + return (self.script, self.side_effect).__hash__() + + def __repr__(self): + return 'CorrectedCommand(script={}, side_effect={}, priority={})'.format( + self.script, self.side_effect, self.priority) + class RulesNamesList(list): """Wrapper a top of list for storing rules names.""" @@ -18,7 +38,6 @@ class RulesNamesList(list): class Settings(dict): - def __getattr__(self, item): return self.get(item) @@ -29,3 +48,60 @@ class Settings(dict): conf = dict(kwargs) conf.update(self) return Settings(conf) + + +class SortedCorrectedCommandsSequence(object): + """List-like collection/wrapper around generator, that: + + - immediately gives access to the first commands through []; + - realises generator and sorts commands on first access to other + commands through [], or when len called. + + """ + + def __init__(self, commands, settings): + self._settings = settings + self._commands = commands + self._cached = self._realise_first() + self._realised = False + + def _realise_first(self): + try: + return [next(self._commands)] + except StopIteration: + return [] + + def _remove_duplicates(self, corrected_commands): + """Removes low-priority duplicates.""" + commands = {command + for command in sorted(corrected_commands, + key=lambda command: -command.priority) + if command.script != self._cached[0]} + return commands + + def _realise(self): + """Realises generator, removes duplicates and sorts commands.""" + commands = self._remove_duplicates(self._commands) + self._cached = [self._cached[0]] + sorted( + commands, key=lambda corrected_command: corrected_command.priority) + self._realised = True + debug('SortedCommandsSequence was realised with: {}, after: {}'.format( + self._cached, '\n'.join(format_stack())), self._settings) + + def __getitem__(self, item): + if item != 0 and not self._realised: + self._realise() + return self._cached[item] + + def __bool__(self): + return bool(self._cached) + + def __len__(self): + if not self._realised: + self._realise() + return len(self._cached) + + def __iter__(self): + if not self._realised: + self._realise() + return iter(self._cached) diff --git a/thefuck/ui.py b/thefuck/ui.py index 146cbd73..36dced97 100644 --- a/thefuck/ui.py +++ b/thefuck/ui.py @@ -88,9 +88,7 @@ def select_command(corrected_commands, settings): logs.show_corrected_command(selector.value, settings) return selector.value - multiple_cmds = len(corrected_commands) > 1 - - selector.on_change(lambda val: logs.confirm_text(val, multiple_cmds, settings)) + selector.on_change(lambda val: logs.confirm_text(val, settings)) for action in read_actions(): if action == SELECT: sys.stderr.write('\n') diff --git a/thefuck/utils.py b/thefuck/utils.py index e874efc4..3c70abb1 100644 --- a/thefuck/utils.py +++ b/thefuck/utils.py @@ -1,5 +1,6 @@ from difflib import get_close_matches from functools import wraps +from decorator import decorator import os import pickle @@ -64,12 +65,9 @@ def wrap_settings(params): print(settings.apt) """ - def decorator(fn): - @wraps(fn) - def wrapper(command, settings): - return fn(command, settings.update(**params)) - return wrapper - return decorator + def _wrap_settings(fn, command, settings): + return fn(command, settings.update(**params)) + return decorator(_wrap_settings) def get_closest(word, possibilities, n=3, cutoff=0.6, fallback_to_first=True): @@ -111,11 +109,9 @@ def replace_argument(script, from_, to): u' {} '.format(from_), u' {} '.format(to), 1) -def eager(fn): - @wraps(fn) - def wrapper(*args, **kwargs): - return list(fn(*args, **kwargs)) - return wrapper +@decorator +def eager(fn, *args, **kwargs): + return list(fn(*args, **kwargs)) @eager @@ -133,3 +129,24 @@ def replace_command(command, broken, matched): new_cmds = get_close_matches(broken, matched, cutoff=0.1) return [replace_argument(command.script, broken, new_cmd.strip()) for new_cmd in new_cmds] + + +@memoize +def is_app(command, *app_names): + """Returns `True` if command is call to one of passed app names.""" + for name in app_names: + if command.script == name \ + or command.script.startswith(u'{} '.format(name)): + return True + return False + + +def for_app(*app_names): + """Specifies that matching script is for on of app names.""" + def _for_app(fn, command, settings): + if is_app(command, *app_names): + return fn(command, settings) + else: + return False + + return decorator(_for_app)