From 14a9cd85aa6d8c89510f0ff88db1b22223db1c38 Mon Sep 17 00:00:00 2001 From: Vladimir Iakovlev Date: Mon, 13 Mar 2017 21:50:13 +0100 Subject: [PATCH] #611: Allow to configure alias automatically by calling `fuck` twice --- setup.py | 2 +- tests/shells/conftest.py | 8 +++ tests/shells/test_bash.py | 9 +++ tests/shells/test_fish.py | 9 +++ tests/shells/test_generic.py | 3 + tests/shells/test_powershell.py | 3 + tests/shells/test_tcsh.py | 9 +++ tests/shells/test_zsh.py | 9 +++ tests/test_not_configured.py | 119 ++++++++++++++++++++++++++++++++ thefuck/logs.py | 29 +++++++- thefuck/main.py | 10 --- thefuck/not_configured.py | 86 +++++++++++++++++++++++ thefuck/shells/bash.py | 9 ++- thefuck/shells/fish.py | 9 ++- thefuck/shells/generic.py | 13 ++++ thefuck/shells/powershell.py | 12 ++-- thefuck/shells/tcsh.py | 9 ++- thefuck/shells/zsh.py | 9 ++- thefuck/utils.py | 33 ++++----- 19 files changed, 336 insertions(+), 54 deletions(-) create mode 100644 tests/test_not_configured.py create mode 100644 thefuck/not_configured.py diff --git a/setup.py b/setup.py index 4149f663..81410f1a 100755 --- a/setup.py +++ b/setup.py @@ -51,4 +51,4 @@ setup(name='thefuck', extras_require=extras_require, entry_points={'console_scripts': [ 'thefuck = thefuck.main:main', - 'fuck = thefuck.main:how_to_configure_alias']}) + 'fuck = thefuck.not_configured:main']}) diff --git a/tests/shells/conftest.py b/tests/shells/conftest.py index de22a744..069673fc 100644 --- a/tests/shells/conftest.py +++ b/tests/shells/conftest.py @@ -20,3 +20,11 @@ def history_lines(mocker): .return_value.readlines.return_value = lines return aux + + +@pytest.fixture +def config_exists(mocker): + path_mock = mocker.patch('thefuck.shells.generic.Path') + return path_mock.return_value \ + .expanduser.return_value \ + .exists diff --git a/tests/shells/test_bash.py b/tests/shells/test_bash.py index 96661b53..669a0985 100644 --- a/tests/shells/test_bash.py +++ b/tests/shells/test_bash.py @@ -61,3 +61,12 @@ class TestBash(object): command = 'git log -p' command_parts = ['git', 'log', '-p'] assert shell.split_command(command) == command_parts + + def test_how_to_configure(self, shell, config_exists): + config_exists.return_value = True + assert shell.how_to_configure().can_configure_automatically + + def test_how_to_configure_when_config_not_found(self, shell, + config_exists): + config_exists.return_value = False + assert not shell.how_to_configure().can_configure_automatically diff --git a/tests/shells/test_fish.py b/tests/shells/test_fish.py index 24780c7b..4e900fae 100644 --- a/tests/shells/test_fish.py +++ b/tests/shells/test_fish.py @@ -95,3 +95,12 @@ class TestFish(object): shell.put_to_history(entry) builtins_open.return_value.__enter__.return_value. \ write.assert_called_once_with(entry_utf8) + + def test_how_to_configure(self, shell, config_exists): + config_exists.return_value = True + assert shell.how_to_configure().can_configure_automatically + + def test_how_to_configure_when_config_not_found(self, shell, + config_exists): + config_exists.return_value = False + assert not shell.how_to_configure().can_configure_automatically diff --git a/tests/shells/test_generic.py b/tests/shells/test_generic.py index 14712e13..81838da7 100644 --- a/tests/shells/test_generic.py +++ b/tests/shells/test_generic.py @@ -37,3 +37,6 @@ class TestGeneric(object): def test_split_command(self, shell): assert shell.split_command('ls') == ['ls'] assert shell.split_command(u'echo café') == [u'echo', u'café'] + + def test_how_to_configure(self, shell): + assert shell.how_to_configure() is None diff --git a/tests/shells/test_powershell.py b/tests/shells/test_powershell.py index d1e7a874..dd6b3628 100644 --- a/tests/shells/test_powershell.py +++ b/tests/shells/test_powershell.py @@ -17,3 +17,6 @@ class TestPowershell(object): assert 'function fuck' in shell.app_alias('fuck') assert 'function FUCK' in shell.app_alias('FUCK') assert 'thefuck' in shell.app_alias('fuck') + + def test_how_to_configure(self, shell): + assert not shell.how_to_configure().can_configure_automatically diff --git a/tests/shells/test_tcsh.py b/tests/shells/test_tcsh.py index 661e5610..9453c1ad 100644 --- a/tests/shells/test_tcsh.py +++ b/tests/shells/test_tcsh.py @@ -48,3 +48,12 @@ class TestTcsh(object): def test_get_history(self, history_lines, shell): history_lines(['ls', 'rm']) assert list(shell.get_history()) == ['ls', 'rm'] + + def test_how_to_configure(self, shell, config_exists): + config_exists.return_value = True + assert shell.how_to_configure().can_configure_automatically + + def test_how_to_configure_when_config_not_found(self, shell, + config_exists): + config_exists.return_value = False + assert not shell.how_to_configure().can_configure_automatically diff --git a/tests/shells/test_zsh.py b/tests/shells/test_zsh.py index f44f0517..43893a09 100644 --- a/tests/shells/test_zsh.py +++ b/tests/shells/test_zsh.py @@ -55,3 +55,12 @@ class TestZsh(object): def test_get_history(self, history_lines, shell): history_lines([': 1432613911:0;ls', ': 1432613916:0;rm']) assert list(shell.get_history()) == ['ls', 'rm'] + + def test_how_to_configure(self, shell, config_exists): + config_exists.return_value = True + assert shell.how_to_configure().can_configure_automatically + + def test_how_to_configure_when_config_not_found(self, shell, + config_exists): + config_exists.return_value = False + assert not shell.how_to_configure().can_configure_automatically diff --git a/tests/test_not_configured.py b/tests/test_not_configured.py new file mode 100644 index 00000000..3691c9e5 --- /dev/null +++ b/tests/test_not_configured.py @@ -0,0 +1,119 @@ +import pytest +from thefuck.shells.generic import ShellConfiguration +from thefuck.not_configured import main + + +@pytest.fixture(autouse=True) +def usage_tracker(mocker): + return mocker.patch( + 'thefuck.not_configured._get_not_configured_usage_tracker_path') + + +def _assert_tracker_updated(usage_tracker, pid): + usage_tracker.return_value \ + .open.return_value \ + .__enter__.return_value \ + .write.assert_called_once_with(str(pid)) + + +def _change_tracker(usage_tracker, pid): + usage_tracker.return_value.exists.return_value = True + usage_tracker.return_value \ + .open.return_value \ + .__enter__.return_value \ + .read.return_value = str(pid) + + +@pytest.fixture(autouse=True) +def shell_pid(mocker): + return mocker.patch('thefuck.not_configured._get_shell_pid') + + +@pytest.fixture(autouse=True) +def shell(mocker): + shell = mocker.patch('thefuck.not_configured.shell') + shell.get_history.return_value = [] + shell.how_to_configure.return_value = ShellConfiguration( + content='eval $(thefuck --alias)', + path='/tmp/.bashrc', + reload='bash', + can_configure_automatically=True) + return shell + + +@pytest.fixture(autouse=True) +def shell_config(mocker): + path_mock = mocker.patch('thefuck.not_configured.Path') + return path_mock.return_value \ + .expanduser.return_value \ + .open.return_value \ + .__enter__.return_value + + +@pytest.fixture(autouse=True) +def logs(mocker): + return mocker.patch('thefuck.not_configured.logs') + + +def test_for_generic_shell(shell, logs): + shell.how_to_configure.return_value = None + main() + logs.how_to_configure_alias.assert_called_once() + + +def test_on_first_run(usage_tracker, shell_pid, logs): + shell_pid.return_value = 12 + usage_tracker.return_value.exists.return_value = False + main() + _assert_tracker_updated(usage_tracker, 12) + logs.how_to_configure_alias.assert_called_once() + + +def test_on_run_after_other_commands(usage_tracker, shell_pid, shell, logs): + shell_pid.return_value = 12 + shell.get_history.return_value = ['fuck', 'ls'] + _change_tracker(usage_tracker, 12) + main() + logs.how_to_configure_alias.assert_called_once() + + +def test_on_first_run_from_current_shell(usage_tracker, shell_pid, + shell, logs): + shell.get_history.return_value = ['fuck'] + shell_pid.return_value = 12 + _change_tracker(usage_tracker, 55) + main() + _assert_tracker_updated(usage_tracker, 12) + logs.how_to_configure_alias.assert_called_once() + + +def test_when_cant_configure_automatically(shell_pid, shell, logs): + shell_pid.return_value = 12 + shell.how_to_configure.return_value = ShellConfiguration( + content='eval $(thefuck --alias)', + path='/tmp/.bashrc', + reload='bash', + can_configure_automatically=False) + main() + logs.how_to_configure_alias.assert_called_once() + + +def test_when_already_configured(usage_tracker, shell_pid, + shell, shell_config, logs): + shell.get_history.return_value = ['fuck'] + shell_pid.return_value = 12 + _change_tracker(usage_tracker, 12) + shell_config.read.return_value = 'eval $(thefuck --alias)' + main() + logs.already_configured.assert_called_once() + + +def test_when_successfuly_configured(usage_tracker, shell_pid, + shell, shell_config, logs): + shell.get_history.return_value = ['fuck'] + shell_pid.return_value = 12 + _change_tracker(usage_tracker, 12) + shell_config.read.return_value = '' + main() + shell_config.write.assert_any_call('eval $(thefuck --alias)') + logs.configured_successfully.assert_called_once() diff --git a/thefuck/logs.py b/thefuck/logs.py index e783c5f8..cd87bd8a 100644 --- a/thefuck/logs.py +++ b/thefuck/logs.py @@ -91,6 +91,33 @@ def how_to_configure_alias(configuration_details): "changes with {bold}{reload}{reset} or restart your shell.".format( bold=color(colorama.Style.BRIGHT), reset=color(colorama.Style.RESET_ALL), - **configuration_details)) + **configuration_details._asdict())) + + if configuration_details.can_configure_automatically: + print( + "Or run {bold}fuck{reset} second time for configuring" + " it automatically.".format( + bold=color(colorama.Style.BRIGHT), + reset=color(colorama.Style.RESET_ALL))) print('More details - https://github.com/nvbn/thefuck#manual-installation') + + +def already_configured(configuration_details): + print( + "Seems like {bold}fuck{reset} alias already configured!\n" + "For applying changes run {bold}{reload}{reset}" + " or restart your shell.".format( + bold=color(colorama.Style.BRIGHT), + reset=color(colorama.Style.RESET_ALL), + reload=configuration_details.reload)) + + +def configured_successfully(configuration_details): + print( + "{bold}fuck{reset} alias configured successfully!\n" + "For applying changes run {bold}{reload}{reset}" + " or restart your shell.".format( + bold=color(colorama.Style.BRIGHT), + reset=color(colorama.Style.RESET_ALL), + reload=configuration_details.reload)) diff --git a/thefuck/main.py b/thefuck/main.py index 5fa9e7c0..565ef384 100644 --- a/thefuck/main.py +++ b/thefuck/main.py @@ -46,16 +46,6 @@ def print_alias(): print(shell.app_alias(alias)) -def how_to_configure_alias(): - """Shows useful information about how-to configure alias. - - It'll be only visible when user type fuck and when alias isn't configured. - - """ - settings.init() - logs.how_to_configure_alias(shell.how_to_configure()) - - def main(): parser = ArgumentParser(prog='thefuck') version = get_installation_info().version diff --git a/thefuck/not_configured.py b/thefuck/not_configured.py new file mode 100644 index 00000000..06d20265 --- /dev/null +++ b/thefuck/not_configured.py @@ -0,0 +1,86 @@ +# Initialize output before importing any module, that can use colorama. +from .system import init_output + +init_output() + +import os # noqa: E402 +from psutil import Process # noqa: E402 +from . import logs # noqa: E402 +from .shells import shell # noqa: E402 +from .conf import settings # noqa: E402 +from .system import Path # noqa: E402 +from .utils import get_cache_dir # noqa: E402 + + +def _get_shell_pid(): + """Returns parent process pid.""" + proc = Process(os.getpid()) + + try: + return proc.parent().pid + except TypeError: + return proc.parent.pid + + +def _get_not_configured_usage_tracker_path(): + """Returns path of special file where we store latest shell pid.""" + return Path(get_cache_dir()).joinpath('thefuck.last_not_configured_run') + + +def _record_first_run(): + """Records shell pid to tracker file.""" + with _get_not_configured_usage_tracker_path().open('w') as tracker: + tracker.write(str(_get_shell_pid())) + + +def _is_second_run(): + """Returns `True` when we know that `fuck` called second time.""" + tracker_path = _get_not_configured_usage_tracker_path() + if not tracker_path.exists() or not shell.get_history()[-1] == 'fuck': + return False + + current_pid = _get_shell_pid() + with tracker_path.open('r') as tracker: + return tracker.read() == str(current_pid) + + +def _is_already_configured(configuration_details): + """Returns `True` when alias already in shell config.""" + path = Path(configuration_details.path).expanduser() + with path.open('r') as shell_config: + return configuration_details.content in shell_config.read() + + +def _configure(configuration_details): + """Adds alias to shell config.""" + path = Path(configuration_details.path).expanduser() + with path.open('a') as shell_config: + shell_config.write('\n') + shell_config.write(configuration_details.content) + shell_config.write('\n') + + +def main(): + """Shows useful information about how-to configure alias on a first run + and configure automatically on a second. + + It'll be only visible when user type fuck and when alias isn't configured. + + """ + settings.init() + configuration_details = shell.how_to_configure() + if ( + configuration_details and + configuration_details.can_configure_automatically + ): + if _is_already_configured(configuration_details): + logs.already_configured(configuration_details) + return + elif _is_second_run(): + _configure(configuration_details) + logs.configured_successfully(configuration_details) + return + else: + _record_first_run() + + logs.how_to_configure_alias(configuration_details) diff --git a/thefuck/shells/bash.py b/thefuck/shells/bash.py index c5ff5359..8122d697 100644 --- a/thefuck/shells/bash.py +++ b/thefuck/shells/bash.py @@ -45,8 +45,7 @@ class Bash(Generic): else: config = 'bash config' - return { - 'content': 'eval $(thefuck --alias)', - 'path': config, - 'reload': u'source {}'.format(config), - } + return self._create_shell_configuration( + content='eval $(thefuck --alias)', + path=config, + reload=u'source {}'.format(config)) diff --git a/thefuck/shells/fish.py b/thefuck/shells/fish.py index e20ce29e..03bdc9b1 100644 --- a/thefuck/shells/fish.py +++ b/thefuck/shells/fish.py @@ -67,11 +67,10 @@ class Fish(Generic): return u'; and '.join(commands) def how_to_configure(self): - return { - 'content': r"eval (thefuck --alias | tr '\n' ';')", - 'path': '~/.config/fish/config.fish', - 'reload': 'fish', - } + return self._create_shell_configuration( + content=r"eval (thefuck --alias | tr '\n' ';')", + path='~/.config/fish/config.fish', + reload='fish') def put_to_history(self, command): try: diff --git a/thefuck/shells/generic.py b/thefuck/shells/generic.py index d1e60823..d8ece219 100644 --- a/thefuck/shells/generic.py +++ b/thefuck/shells/generic.py @@ -2,8 +2,14 @@ import io import os import shlex import six +from collections import namedtuple from ..utils import memoize from ..conf import settings +from ..system import Path + + +ShellConfiguration = namedtuple('ShellConfiguration', ( + 'content', 'path', 'reload', 'can_configure_automatically')) class Generic(object): @@ -116,3 +122,10 @@ class Generic(object): 'shift', 'shopt', 'source', 'suspend', 'test', 'times', 'trap', 'type', 'typeset', 'ulimit', 'umask', 'unalias', 'unset', 'until', 'wait', 'while'] + + def _create_shell_configuration(self, content, path, reload): + return ShellConfiguration( + content=content, + path=path, + reload=reload, + can_configure_automatically=Path(path).expanduser().exists()) diff --git a/thefuck/shells/powershell.py b/thefuck/shells/powershell.py index bf954bf3..e00dc809 100644 --- a/thefuck/shells/powershell.py +++ b/thefuck/shells/powershell.py @@ -1,4 +1,4 @@ -from .generic import Generic +from .generic import Generic, ShellConfiguration class Powershell(Generic): @@ -18,8 +18,8 @@ class Powershell(Generic): return u' -and '.join('({0})'.format(c) for c in commands) def how_to_configure(self): - return { - 'content': 'iex "thefuck --alias"', - 'path': '$profile', - 'reload': '& $profile', - } + return ShellConfiguration( + content='iex "thefuck --alias"', + path='$profile', + reload='& $profile', + can_configure_automatically=False) diff --git a/thefuck/shells/tcsh.py b/thefuck/shells/tcsh.py index 9c9ee0d4..d43e258e 100644 --- a/thefuck/shells/tcsh.py +++ b/thefuck/shells/tcsh.py @@ -31,8 +31,7 @@ class Tcsh(Generic): return u'#+{}\n{}\n'.format(int(time()), command_script) def how_to_configure(self): - return { - 'content': 'eval `thefuck --alias`', - 'path': '~/.tcshrc', - 'reload': 'tcsh', - } + return self._create_shell_configuration( + content='eval `thefuck --alias`', + path='~/.tcshrc', + reload='tcsh') diff --git a/thefuck/shells/zsh.py b/thefuck/shells/zsh.py index 24551462..14a74a16 100644 --- a/thefuck/shells/zsh.py +++ b/thefuck/shells/zsh.py @@ -45,8 +45,7 @@ class Zsh(Generic): return '' def how_to_configure(self): - return { - 'content': 'eval $(thefuck --alias)', - 'path': '~/.zshrc', - 'reload': 'source ~/.zshrc', - } + return self._create_shell_configuration( + content='eval $(thefuck --alias)', + path='~/.zshrc', + reload='source ~/.zshrc') diff --git a/thefuck/utils.py b/thefuck/utils.py index 1663f0d1..d9c6d0cc 100644 --- a/thefuck/utils.py +++ b/thefuck/utils.py @@ -178,6 +178,21 @@ def for_app(*app_names, **kwargs): return decorator(_for_app) +def get_cache_dir(): + default_xdg_cache_dir = os.path.expanduser("~/.cache") + cache_dir = os.getenv("XDG_CACHE_HOME", default_xdg_cache_dir) + + # Ensure the cache_path exists, Python 2 does not have the exist_ok + # parameter + try: + os.makedirs(cache_dir) + except OSError: + if not os.path.isdir(cache_dir): + raise + + return cache_dir + + def cache(*depends_on): """Caches function result in temporary file. @@ -194,21 +209,6 @@ def cache(*depends_on): except OSError: return '0' - def _get_cache_path(): - default_xdg_cache_dir = os.path.expanduser("~/.cache") - cache_dir = os.getenv("XDG_CACHE_HOME", default_xdg_cache_dir) - cache_path = Path(cache_dir).joinpath('thefuck').as_posix() - - # Ensure the cache_path exists, Python 2 does not have the exist_ok - # parameter - try: - os.makedirs(cache_dir) - except OSError: - if not os.path.isdir(cache_dir): - raise - - return cache_path - @decorator def _cache(fn, *args, **kwargs): if cache.disabled: @@ -219,7 +219,8 @@ def cache(*depends_on): key = '{}.{}'.format(fn.__module__, repr(fn).split('at')[0]) etag = '.'.join(_get_mtime(name) for name in depends_on) - cache_path = _get_cache_path() + cache_dir = get_cache_dir() + cache_path = Path(cache_dir).joinpath('thefuck').as_posix() try: with closing(shelve.open(cache_path)) as db: