diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index f831da6..4c0b95a 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -6,11 +6,8 @@ update The Fuck and see if the bug is still there. --> if not, just open an issue on [GitHub](https://github.com/nvbn/thefuck) with the following basic information: --> -The output of `thefuck --version` (something like `The Fuck 3.1 using Python 3.5.0`): - - FILL THIS IN - -Your shell and its version (`bash`, `zsh`, *Windows PowerShell*, etc.): +The output of `thefuck --version` (something like `The Fuck 3.1 using Python +3.5.0 and Bash 4.4.12(1)-release`): FILL THIS IN diff --git a/.travis.yml b/.travis.yml index 22184cb..19f345e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,10 +2,21 @@ language: python sudo: false matrix: include: + - os: linux + dist: xenial + python: "nightly" + - os: linux + dist: xenial + python: "3.8-dev" + - os: linux + dist: xenial + python: "3.7-dev" - os: linux dist: xenial python: "3.7" - sudo: true + - os: linux + dist: trusty + python: "3.6-dev" - os: linux dist: trusty python: "3.6" @@ -20,6 +31,11 @@ matrix: python: "2.7" - os: osx language: generic + allow_failures: + - python: nightly + - python: 3.8-dev + - python: 3.7-dev + - python: 3.6-dev services: - docker addons: diff --git a/README.md b/README.md index 0be887a..d0fb49c 100644 --- a/README.md +++ b/README.md @@ -390,7 +390,8 @@ Several *The Fuck* parameters can be changed in the file `$XDG_CONFIG_HOME/thefu * `history_limit` – numeric value of how many history commands will be scanned, like `2000`; * `alter_history` – push fixed command to history, by default `True`; * `wait_slow_command` – max amount of time in seconds for getting previous command output if it in `slow_commands` list; -* `slow_commands` – list of slow commands. +* `slow_commands` – list of slow commands; +* `num_close_matches` – maximum number of close matches to suggest, by default `3`. An example of `settings.py`: @@ -405,6 +406,7 @@ debug = False history_limit = 9999 wait_slow_command = 20 slow_commands = ['react-native', 'gradle'] +num_close_matches = 5 ``` Or via environment variables: @@ -420,7 +422,8 @@ rule with lower `priority` will be matched first; * `THEFUCK_HISTORY_LIMIT` – how many history commands will be scanned, like `2000`; * `THEFUCK_ALTER_HISTORY` – push fixed command to history `true/false`; * `THEFUCK_WAIT_SLOW_COMMAND` – max amount of time in seconds for getting previous command output if it in `slow_commands` list; -* `THEFUCK_SLOW_COMMANDS` – list of slow commands, like `lein:gradle`. +* `THEFUCK_SLOW_COMMANDS` – list of slow commands, like `lein:gradle`; +* `THEFUCK_NUM_CLOSE_MATCHES` – maximum number of close matches to suggest, like `5`. For example: @@ -432,6 +435,7 @@ export THEFUCK_WAIT_COMMAND=10 export THEFUCK_NO_COLORS='false' export THEFUCK_PRIORITY='no_command=9999:apt_get=100' export THEFUCK_HISTORY_LIMIT='2000' +export THEFUCK_NUM_CLOSE_MATCHES='5' ``` ## Third-party packages with rules diff --git a/tests/output_readers/test_rerun.py b/tests/output_readers/test_rerun.py new file mode 100644 index 0000000..02dbd40 --- /dev/null +++ b/tests/output_readers/test_rerun.py @@ -0,0 +1,58 @@ +# -*- encoding: utf-8 -*- + +from mock import Mock, patch +from psutil import AccessDenied, TimeoutExpired + +from thefuck.output_readers import rerun + + +class TestRerun(object): + def setup_method(self, test_method): + self.patcher = patch('thefuck.output_readers.rerun.Process') + process_mock = self.patcher.start() + self.proc_mock = process_mock.return_value = Mock() + + def teardown_method(self, test_method): + self.patcher.stop() + + @patch('thefuck.output_readers.rerun._wait_output', return_value=False) + @patch('thefuck.output_readers.rerun.Popen') + def test_get_output(self, popen_mock, wait_output_mock): + popen_mock.return_value.stdout.read.return_value = b'output' + assert rerun.get_output('', '') is None + wait_output_mock.assert_called_once() + + def test_wait_output_is_slow(self, settings): + assert rerun._wait_output(Mock(), True) + self.proc_mock.wait.assert_called_once_with(settings.wait_slow_command) + + def test_wait_output_is_not_slow(self, settings): + assert rerun._wait_output(Mock(), False) + self.proc_mock.wait.assert_called_once_with(settings.wait_command) + + @patch('thefuck.output_readers.rerun._kill_process') + def test_wait_output_timeout(self, kill_process_mock): + self.proc_mock.wait.side_effect = TimeoutExpired(3) + self.proc_mock.children.return_value = [] + assert not rerun._wait_output(Mock(), False) + kill_process_mock.assert_called_once_with(self.proc_mock) + + @patch('thefuck.output_readers.rerun._kill_process') + def test_wait_output_timeout_children(self, kill_process_mock): + self.proc_mock.wait.side_effect = TimeoutExpired(3) + self.proc_mock.children.return_value = [Mock()] * 2 + assert not rerun._wait_output(Mock(), False) + assert kill_process_mock.call_count == 3 + + def test_kill_process(self): + proc = Mock() + rerun._kill_process(proc) + proc.kill.assert_called_once_with() + + @patch('thefuck.output_readers.rerun.logs') + def test_kill_process_access_denied(self, logs_mock): + proc = Mock() + proc.kill.side_effect = AccessDenied() + rerun._kill_process(proc) + proc.kill.assert_called_once_with() + logs_mock.debug.assert_called_once() diff --git a/tests/shells/test_bash.py b/tests/shells/test_bash.py index b10ae0e..1bb3665 100644 --- a/tests/shells/test_bash.py +++ b/tests/shells/test_bash.py @@ -73,3 +73,8 @@ class TestBash(object): config_exists): config_exists.return_value = False assert not shell.how_to_configure().can_configure_automatically + + def test_info(self, shell, mocker): + patch = mocker.patch('thefuck.shells.bash.Popen') + patch.return_value.stdout.read.side_effect = [b'3.5.9'] + assert shell.info() == 'Bash 3.5.9' diff --git a/tests/shells/test_fish.py b/tests/shells/test_fish.py index 815382b..6d8a66f 100644 --- a/tests/shells/test_fish.py +++ b/tests/shells/test_fish.py @@ -112,3 +112,7 @@ class TestFish(object): config_exists): config_exists.return_value = False assert not shell.how_to_configure().can_configure_automatically + + def test_info(self, shell, Popen): + Popen.return_value.stdout.read.side_effect = [b'3.5.9'] + assert shell.info() == 'Fish Shell 3.5.9' diff --git a/tests/shells/test_zsh.py b/tests/shells/test_zsh.py index fc20a06..cbbf3f5 100644 --- a/tests/shells/test_zsh.py +++ b/tests/shells/test_zsh.py @@ -68,3 +68,8 @@ class TestZsh(object): config_exists): config_exists.return_value = False assert not shell.how_to_configure().can_configure_automatically + + def test_info(self, shell, mocker): + patch = mocker.patch('thefuck.shells.zsh.Popen') + patch.return_value.stdout.read.side_effect = [b'3.5.9'] + assert shell.info() == 'ZSH 3.5.9' diff --git a/tests/test_conf.py b/tests/test_conf.py index c9f0b18..7d0fe4b 100644 --- a/tests/test_conf.py +++ b/tests/test_conf.py @@ -53,7 +53,8 @@ class TestSettingsFromEnv(object): 'THEFUCK_NO_COLORS': 'false', 'THEFUCK_PRIORITY': 'bash=10:lisp=wrong:vim=15', 'THEFUCK_WAIT_SLOW_COMMAND': '999', - 'THEFUCK_SLOW_COMMANDS': 'lein:react-native:./gradlew'}) + 'THEFUCK_SLOW_COMMANDS': 'lein:react-native:./gradlew', + 'THEFUCK_NUM_CLOSE_MATCHES': '359'}) settings.init() assert settings.rules == ['bash', 'lisp'] assert settings.exclude_rules == ['git', 'vim'] @@ -63,6 +64,7 @@ class TestSettingsFromEnv(object): assert settings.priority == {'bash': 10, 'vim': 15} assert settings.wait_slow_command == 999 assert settings.slow_commands == ['lein', 'react-native', './gradlew'] + assert settings.num_close_matches == 359 def test_from_env_with_DEFAULT(self, os_environ, settings): os_environ.update({'THEFUCK_RULES': 'DEFAULT_RULES:bash:lisp'}) diff --git a/tests/test_utils.py b/tests/test_utils.py index 92b7417..cf9208b 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -2,11 +2,11 @@ import pytest import warnings -from mock import Mock +from mock import Mock, patch from thefuck.utils import default_settings, \ memoize, get_closest, get_all_executables, replace_argument, \ get_all_matched_commands, is_app, for_app, cache, \ - get_valid_history_without_current, _cache + get_valid_history_without_current, _cache, get_close_matches from thefuck.types import Command @@ -50,6 +50,18 @@ class TestGetClosest(object): fallback_to_first=False) is None +class TestGetCloseMatches(object): + @patch('thefuck.utils.difflib_get_close_matches') + def test_call_with_n(self, difflib_mock): + get_close_matches('', [], 1) + assert difflib_mock.call_args[0][2] == 1 + + @patch('thefuck.utils.difflib_get_close_matches') + def test_call_without_n(self, difflib_mock, settings): + get_close_matches('', []) + assert difflib_mock.call_args[0][2] == settings.get('num_close_matches') + + @pytest.fixture def get_aliases(mocker): mocker.patch('thefuck.shells.shell.get_aliases', diff --git a/thefuck/conf.py b/thefuck/conf.py index 1f69faf..b551963 100644 --- a/thefuck/conf.py +++ b/thefuck/conf.py @@ -95,7 +95,8 @@ class Settings(dict): return self._rules_from_env(val) elif attr == 'priority': return dict(self._priority_from_env(val)) - elif attr in ('wait_command', 'history_limit', 'wait_slow_command'): + elif attr in ('wait_command', 'history_limit', 'wait_slow_command', + 'num_close_matches'): return int(val) elif attr in ('require_confirmation', 'no_colors', 'debug', 'alter_history', 'instant_mode'): diff --git a/thefuck/const.py b/thefuck/const.py index 2009d49..d272f1b 100644 --- a/thefuck/const.py +++ b/thefuck/const.py @@ -42,6 +42,7 @@ DEFAULT_SETTINGS = {'rules': DEFAULT_RULES, './gradlew', 'vagrant'], 'repeat': False, 'instant_mode': False, + 'num_close_matches': 3, 'env': {'LC_ALL': 'C', 'LANG': 'C', 'GIT_TRACE': '1'}} ENV_TO_ATTR = {'THEFUCK_RULES': 'rules', @@ -56,7 +57,8 @@ ENV_TO_ATTR = {'THEFUCK_RULES': 'rules', 'THEFUCK_WAIT_SLOW_COMMAND': 'wait_slow_command', 'THEFUCK_SLOW_COMMANDS': 'slow_commands', 'THEFUCK_REPEAT': 'repeat', - 'THEFUCK_INSTANT_MODE': 'instant_mode'} + 'THEFUCK_INSTANT_MODE': 'instant_mode', + 'THEFUCK_NUM_CLOSE_MATCHES': 'num_close_matches'} SETTINGS_HEADER = u"""# The Fuck settings file # diff --git a/thefuck/entrypoints/main.py b/thefuck/entrypoints/main.py index 63f9d8c..0134687 100644 --- a/thefuck/entrypoints/main.py +++ b/thefuck/entrypoints/main.py @@ -8,6 +8,7 @@ import sys # noqa: E402 from .. import logs # noqa: E402 from ..argument_parser import Parser # noqa: E402 from ..utils import get_installation_info # noqa: E402 +from ..shells import shell # noqa: E402 from .alias import print_alias # noqa: E402 from .fix_command import fix_command # noqa: E402 @@ -20,7 +21,7 @@ def main(): parser.print_help() elif known_args.version: logs.version(get_installation_info().version, - sys.version.split()[0]) + sys.version.split()[0], shell.info()) elif known_args.command or 'TF_HISTORY' in os.environ: fix_command(known_args) elif known_args.alias: diff --git a/thefuck/logs.py b/thefuck/logs.py index aab3aca..0c50765 100644 --- a/thefuck/logs.py +++ b/thefuck/logs.py @@ -134,7 +134,8 @@ def configured_successfully(configuration_details): reload=configuration_details.reload)) -def version(thefuck_version, python_version): +def version(thefuck_version, python_version, shell_info): sys.stderr.write( - u'The Fuck {} using Python {}\n'.format(thefuck_version, - python_version)) + u'The Fuck {} using Python {} and {}\n'.format(thefuck_version, + python_version, + shell_info)) diff --git a/thefuck/output_readers/rerun.py b/thefuck/output_readers/rerun.py index c5b439a..74cbfbf 100644 --- a/thefuck/output_readers/rerun.py +++ b/thefuck/output_readers/rerun.py @@ -1,11 +1,25 @@ import os import shlex from subprocess import Popen, PIPE, STDOUT -from psutil import Process, TimeoutExpired +from psutil import AccessDenied, Process, TimeoutExpired from .. import logs from ..conf import settings +def _kill_process(proc): + """Tries to kill the process otherwise just logs a debug message, the + process will be killed when thefuck terminates. + + :type proc: Process + + """ + try: + proc.kill() + except AccessDenied: + logs.debug(u'Rerun: process PID {} ({}) could not be terminated'.format( + proc.pid, proc.exe())) + + def _wait_output(popen, is_slow): """Returns `True` if we can get output of the command in the `settings.wait_command` time. @@ -23,8 +37,8 @@ def _wait_output(popen, is_slow): return True except TimeoutExpired: for child in proc.children(recursive=True): - child.kill() - proc.kill() + _kill_process(child) + _kill_process(proc) return False diff --git a/thefuck/rules/brew_install.py b/thefuck/rules/brew_install.py index 08d3ea0..5848764 100644 --- a/thefuck/rules/brew_install.py +++ b/thefuck/rules/brew_install.py @@ -20,7 +20,7 @@ def _get_formulas(): def _get_similar_formula(formula_name): - return get_closest(formula_name, _get_formulas(), 1, 0.85) + return get_closest(formula_name, _get_formulas(), cutoff=0.85) def match(command): diff --git a/thefuck/rules/cd_correction.py b/thefuck/rules/cd_correction.py index 376e51e..dc45636 100644 --- a/thefuck/rules/cd_correction.py +++ b/thefuck/rules/cd_correction.py @@ -2,10 +2,9 @@ import os import six -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 +from thefuck.utils import for_app, get_close_matches __author__ = "mmussomele" diff --git a/thefuck/rules/git_rebase_merge_dir.py b/thefuck/rules/git_rebase_merge_dir.py index c4b3f68..8251dd1 100644 --- a/thefuck/rules/git_rebase_merge_dir.py +++ b/thefuck/rules/git_rebase_merge_dir.py @@ -1,4 +1,4 @@ -from difflib import get_close_matches +from thefuck.utils import get_close_matches from thefuck.specific.git import git_support diff --git a/thefuck/rules/history.py b/thefuck/rules/history.py index 8294963..0ebe548 100644 --- a/thefuck/rules/history.py +++ b/thefuck/rules/history.py @@ -1,5 +1,5 @@ -from difflib import get_close_matches -from thefuck.utils import get_closest, get_valid_history_without_current +from thefuck.utils import get_close_matches, get_closest, \ + get_valid_history_without_current def match(command): diff --git a/thefuck/rules/mvn_unknown_lifecycle_phase.py b/thefuck/rules/mvn_unknown_lifecycle_phase.py index ff3c54e..fa2cf20 100644 --- a/thefuck/rules/mvn_unknown_lifecycle_phase.py +++ b/thefuck/rules/mvn_unknown_lifecycle_phase.py @@ -1,5 +1,4 @@ -from thefuck.utils import replace_command, for_app -from difflib import get_close_matches +from thefuck.utils import for_app, get_close_matches, replace_command import re @@ -25,8 +24,7 @@ def get_new_command(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) + failed_lifecycle.group(1), available_lifecycles.group(1).split(", ")) return replace_command(command, failed_lifecycle.group(1), selected_lifecycle) else: return [] diff --git a/thefuck/rules/no_command.py b/thefuck/rules/no_command.py index ba5f0e3..0e5a22d 100644 --- a/thefuck/rules/no_command.py +++ b/thefuck/rules/no_command.py @@ -1,5 +1,4 @@ -from difflib import get_close_matches -from thefuck.utils import get_all_executables, \ +from thefuck.utils import get_all_executables, get_close_matches, \ get_valid_history_without_current, get_closest, which from thefuck.specific.sudo import sudo_support diff --git a/thefuck/shells/bash.py b/thefuck/shells/bash.py index 6021fea..d8a5318 100644 --- a/thefuck/shells/bash.py +++ b/thefuck/shells/bash.py @@ -1,9 +1,10 @@ import os +from subprocess import Popen, PIPE from tempfile import gettempdir from uuid import uuid4 from ..conf import settings from ..const import ARGUMENT_PLACEHOLDER, USER_COMMAND_MARK -from ..utils import memoize +from ..utils import DEVNULL, memoize from .generic import Generic @@ -81,3 +82,10 @@ class Bash(Generic): content=u'eval $(thefuck --alias)', path=config, reload=u'source {}'.format(config)) + + def info(self): + """Returns the name and version of the current shell""" + proc = Popen(['bash', '-c', 'echo $BASH_VERSION'], + stdout=PIPE, stderr=DEVNULL) + version = proc.stdout.read().decode('utf-8').strip() + return u'Bash {}'.format(version) diff --git a/thefuck/shells/fish.py b/thefuck/shells/fish.py index 5693404..1435f90 100644 --- a/thefuck/shells/fish.py +++ b/thefuck/shells/fish.py @@ -103,6 +103,13 @@ class Fish(Generic): path='~/.config/fish/config.fish', reload='fish') + def info(self): + """Returns the name and version of the current shell""" + proc = Popen(['fish', '-c', 'echo $FISH_VERSION'], + stdout=PIPE, stderr=DEVNULL) + version = proc.stdout.read().decode('utf-8').strip() + return u'Fish Shell {}'.format(version) + def put_to_history(self, command): try: return self._put_to_history(command) diff --git a/thefuck/shells/generic.py b/thefuck/shells/generic.py index a810d8a..6d1eb96 100644 --- a/thefuck/shells/generic.py +++ b/thefuck/shells/generic.py @@ -131,6 +131,10 @@ class Generic(object): 'type', 'typeset', 'ulimit', 'umask', 'unalias', 'unset', 'until', 'wait', 'while'] + def info(self): + """Returns the name and version of the current shell""" + return 'Generic Shell' + def _create_shell_configuration(self, content, path, reload): return ShellConfiguration( content=content, diff --git a/thefuck/shells/zsh.py b/thefuck/shells/zsh.py index e771918..d4ea340 100644 --- a/thefuck/shells/zsh.py +++ b/thefuck/shells/zsh.py @@ -1,10 +1,11 @@ from time import time import os +from subprocess import Popen, PIPE from tempfile import gettempdir from uuid import uuid4 from ..conf import settings from ..const import ARGUMENT_PLACEHOLDER, USER_COMMAND_MARK -from ..utils import memoize +from ..utils import DEVNULL, memoize from .generic import Generic @@ -85,3 +86,10 @@ class Zsh(Generic): content=u'eval $(thefuck --alias)', path='~/.zshrc', reload='source ~/.zshrc') + + def info(self): + """Returns the name and version of the current shell""" + proc = Popen(['zsh', '-c', 'echo $ZSH_VERSION'], + stdout=PIPE, stderr=DEVNULL) + version = proc.stdout.read().decode('utf-8').strip() + return u'ZSH {}'.format(version) diff --git a/thefuck/utils.py b/thefuck/utils.py index fa755bf..5610278 100644 --- a/thefuck/utils.py +++ b/thefuck/utils.py @@ -5,7 +5,7 @@ import re import shelve import six from decorator import decorator -from difflib import get_close_matches +from difflib import get_close_matches as difflib_get_close_matches from functools import wraps from .logs import warn from .conf import settings @@ -86,16 +86,23 @@ def default_settings(params): return decorator(_default_settings) -def get_closest(word, possibilities, n=3, cutoff=0.6, fallback_to_first=True): +def get_closest(word, possibilities, cutoff=0.6, fallback_to_first=True): """Returns closest match or just first from possibilities.""" possibilities = list(possibilities) try: - return get_close_matches(word, possibilities, n, cutoff)[0] + return difflib_get_close_matches(word, possibilities, 1, cutoff)[0] except IndexError: if fallback_to_first: return possibilities[0] +def get_close_matches(word, possibilities, n=None, cutoff=0.6): + """Overrides `difflib.get_close_match` to controle argument `n`.""" + if n is None: + n = settings.num_close_matches + return difflib_get_close_matches(word, possibilities, n, cutoff) + + @memoize def get_all_executables(): from thefuck.shells import shell