diff --git a/README.md b/README.md index aaaa46af..650ea8d7 100644 --- a/README.md +++ b/README.md @@ -196,11 +196,18 @@ def get_new_command(command, settings): The Fuck has a few settings parameters, they can be changed in `~/.thefuck/settings.py`: -* `rules` – list of enabled rules, by default all; +* `rules` – list of enabled rules, by default `thefuck.conf.DEFAULT`; * `require_confirmation` – require confirmation before running new command, by default `False`; * `wait_command` – max amount of time in seconds for getting previous command output; * `no_colors` – disable colored output. +Or via environment variables: + +* `THEFUCK_RULES` – list of enabled rules, like `DEFAULT:rm_root` or `sudo:no_command`; +* `THEFUCK_REQUIRE_CONFIRMATION` – require confirmation before running new command, `true/false`; +* `THEFUCK_WAIT_COMMAND` – max amount of time in seconds for getting previous command output; +* `THEFUCK_NO_COLORS` – disable colored output, `true/false`. + ## Developing Install `The Fuck` for development: diff --git a/tests/test_conf.py b/tests/test_conf.py new file mode 100644 index 00000000..c74d3430 --- /dev/null +++ b/tests/test_conf.py @@ -0,0 +1,75 @@ +from mock import patch, Mock +from thefuck.main import Rule +from thefuck import conf + + +def test_rules_list(): + assert conf.RulesList(['bash', 'lisp']) == ['bash', 'lisp'] + assert conf.RulesList(['bash', 'lisp']) == conf.RulesList(['bash', 'lisp']) + assert Rule('lisp', None, None, False) in conf.RulesList(['lisp']) + assert Rule('bash', None, None, False) not in conf.RulesList(['lisp']) + + +def test_default(): + assert Rule('test', None, None, True) in conf.DEFAULT + assert Rule('test', None, None, False) not in conf.DEFAULT + assert Rule('test', None, None, False) in (conf.DEFAULT + ['test']) + + +def test_settings_defaults(): + with patch('thefuck.conf.load_source', return_value=object()), \ + patch('thefuck.conf.os.environ', new_callable=lambda: {}): + for key, val in conf.Settings.defaults.items(): + assert getattr(conf.Settings(Mock()), key) == val + + +def test_settings_from_file(): + with patch('thefuck.conf.load_source', return_value=Mock(rules=['test'], + wait_command=10, + require_confirmation=True, + no_colors=True)), \ + patch('thefuck.conf.os.environ', new_callable=lambda: {}): + settings = conf.Settings(Mock()) + assert settings.rules == ['test'] + assert settings.wait_command == 10 + assert settings.require_confirmation is True + assert settings.no_colors is True + + +def test_settings_from_file_with_DEFAULT(): + with patch('thefuck.conf.load_source', return_value=Mock(rules=conf.DEFAULT + ['test'], + wait_command=10, + require_confirmation=True, + no_colors=True)), \ + patch('thefuck.conf.os.environ', new_callable=lambda: {}): + settings = conf.Settings(Mock()) + assert settings.rules == conf.DEFAULT + ['test'] + + +def test_settings_from_env(): + with patch('thefuck.conf.load_source', return_value=Mock(rules=['test'], + wait_command=10)), \ + patch('thefuck.conf.os.environ', + new_callable=lambda: {'THEFUCK_RULES': 'bash:lisp', + 'THEFUCK_WAIT_COMMAND': '55', + 'THEFUCK_REQUIRE_CONFIRMATION': 'true', + 'THEFUCK_NO_COLORS': 'false'}): + settings = conf.Settings(Mock()) + assert settings.rules == ['bash', 'lisp'] + assert settings.wait_command == 55 + assert settings.require_confirmation is True + assert settings.no_colors is False + + +def test_settings_from_env_with_DEFAULT(): + with patch('thefuck.conf.load_source', return_value=Mock()), \ + patch('thefuck.conf.os.environ', new_callable=lambda: {'THEFUCK_RULES': 'DEFAULT:bash:lisp'}): + settings = conf.Settings(Mock()) + assert settings.rules == conf.DEFAULT + ['bash', 'lisp'] + + +def test_update_settings(): + settings = conf.BaseSettings({'key': 'val'}) + new_settings = settings.update(key='new-val') + assert new_settings.key == 'new-val' + assert settings.key == 'val' diff --git a/tests/test_main.py b/tests/test_main.py index 6ad6e136..12f06ea4 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,25 +1,7 @@ from subprocess import PIPE from pathlib import PosixPath, Path from mock import patch, Mock -from thefuck import main - - -def test_get_settings(): - with patch('thefuck.main.load_source', return_value=Mock(rules=['bash'])): - assert main.get_settings(Path('/')).rules == ['bash'] - with patch('thefuck.main.load_source', return_value=Mock(spec=[])): - assert main.get_settings(Path('/')).rules is None - - -def test_is_rule_enabled(): - assert main.is_rule_enabled(Mock(rules=None), - main.Rule('bash', None, None, True)) - assert not main.is_rule_enabled(Mock(rules=None), - main.Rule('bash', None, None, False)) - assert main.is_rule_enabled(Mock(rules=['bash']), - main.Rule('bash', None, None, True)) - assert not main.is_rule_enabled(Mock(rules=['bash']), - main.Rule('lisp', None, None, True)) +from thefuck import main, conf def test_load_rule(): @@ -43,14 +25,16 @@ def test_get_rules(): glob.return_value = [PosixPath('bash.py'), PosixPath('lisp.py')] assert list(main.get_rules( Path('~'), - Mock(rules=None))) == [main.Rule('bash', 'bash', 'bash', True), - main.Rule('lisp', 'lisp', 'lisp', True), - main.Rule('bash', 'bash', 'bash', True), - main.Rule('lisp', 'lisp', 'lisp', True)] + Mock(rules=conf.DEFAULT))) \ + == [main.Rule('bash', 'bash', 'bash', True), + main.Rule('lisp', 'lisp', 'lisp', True), + main.Rule('bash', 'bash', 'bash', True), + main.Rule('lisp', 'lisp', 'lisp', True)] assert list(main.get_rules( Path('~'), - Mock(rules=['bash']))) == [main.Rule('bash', 'bash', 'bash', True), - main.Rule('bash', 'bash', 'bash', True)] + Mock(rules=conf.RulesList(['bash'])))) \ + == [main.Rule('bash', 'bash', 'bash', True), + main.Rule('bash', 'bash', 'bash', True)] def test_get_command(): diff --git a/tests/test_utils.py b/tests/test_utils.py index fed491a3..e1ec93d5 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,6 +1,15 @@ from mock import Mock -from thefuck.utils import sudo_support +from thefuck.utils import sudo_support, wrap_settings from thefuck.main import Command +from thefuck.conf import BaseSettings + + +def test_wrap_settings(): + fn = lambda _, settings: settings._conf + assert wrap_settings({'key': 'val'})(fn)(None, BaseSettings({})) \ + == {'key': 'val'} + assert wrap_settings({'key': 'new-val'})(fn)( + None, BaseSettings({'key': 'val'})) == {'key': 'new-val'} def test_sudo_support(): diff --git a/thefuck/conf.py b/thefuck/conf.py new file mode 100644 index 00000000..4593905a --- /dev/null +++ b/thefuck/conf.py @@ -0,0 +1,120 @@ +from copy import copy +from imp import load_source +import os +import sys +from six import text_type +from . import logs + + +class RulesList(object): + """Wrapper a top of list for string rules names.""" + + def __init__(self, rules): + self.rules = rules + + def __contains__(self, item): + return item.name in self.rules + + def __getattr__(self, item): + return getattr(self.rules, item) + + def __eq__(self, other): + return self.rules == other + + +class _DefaultRules(RulesList): + def __add__(self, items): + return _DefaultRules(self.rules + items) + + def __contains__(self, item): + return item.enabled_by_default or \ + super(_DefaultRules, self).__contains__(item) + + def __eq__(self, other): + if isinstance(other, _DefaultRules): + return self.rules == other.rules + else: + return False + + +DEFAULT = _DefaultRules([]) + + +class BaseSettings(object): + def __init__(self, conf): + self._conf = conf + + def __getattr__(self, item): + return self._conf.get(item) + + def update(self, **kwargs): + """Returns new settings with new values from `kwargs`.""" + conf = copy(self._conf) + conf.update(kwargs) + return BaseSettings(conf) + + +class Settings(BaseSettings): + """Settings loaded from defaults/file/env.""" + defaults = {'rules': DEFAULT, + 'wait_command': 3, + 'require_confirmation': False, + 'no_colors': False} + + env_to_attr = {'THEFUCK_RULES': 'rules', + 'THEFUCK_WAIT_COMMAND': 'wait_command', + 'THEFUCK_REQUIRE_CONFIRMATION': 'require_confirmation', + 'THEFUCK_NO_COLORS': 'no_colors'} + + def __init__(self, user_dir): + super(Settings, self).__init__(self._load_conf(user_dir)) + + def _load_conf(self, user_dir): + conf = copy(self.defaults) + try: + conf.update(self._load_from_file(user_dir)) + except: + logs.exception("Can't load settings from file", + sys.exc_info(), + BaseSettings(conf)) + try: + conf.update(self._load_from_env()) + except: + logs.exception("Can't load settings from env", + sys.exc_info(), + BaseSettings(conf)) + if not isinstance(conf['rules'], RulesList): + conf['rules'] = RulesList(conf['rules']) + return conf + + def _load_from_file(self, user_dir): + """Loads settings from file.""" + settings = load_source('settings', + text_type(user_dir.joinpath('settings.py'))) + return {key: getattr(settings, key) + for key in self.defaults.keys() + if hasattr(settings, key)} + + def _load_from_env(self): + """Loads settings from env.""" + return {attr: self._val_from_env(env, attr) + for env, attr in self.env_to_attr.items() + if env in os.environ} + + def _val_from_env(self, env, attr): + """Transforms env-strings to python.""" + val = os.environ[env] + if attr == 'rules': + val = self._rules_from_env(val) + elif attr == 'wait_command': + val = int(val) + elif attr in ('require_confirmation', 'no_colors'): + val = val.lower() == 'true' + return val + + def _rules_from_env(self, val): + """Transforms rules list from env-string to python.""" + val = val.split(':') + if 'DEFAULT' in val: + val = DEFAULT + [rule for rule in val if rule != 'DEFAULT'] + return val diff --git a/thefuck/logs.py b/thefuck/logs.py index 9bfb02bc..b21b2451 100644 --- a/thefuck/logs.py +++ b/thefuck/logs.py @@ -11,17 +11,21 @@ def color(color_, settings): return color_ -def rule_failed(rule, exc_info, settings): +def exception(title, exc_info, settings): sys.stderr.write( - u'{warn}[WARN] Rule {name}:{reset}\n{trace}' + u'{warn}[WARN] {title}:{reset}\n{trace}' u'{warn}----------------------------{reset}\n\n'.format( warn=color(colorama.Back.RED + colorama.Fore.WHITE + colorama.Style.BRIGHT, settings), reset=color(colorama.Style.RESET_ALL, settings), - name=rule.name, + title=title, trace=''.join(format_exception(*exc_info)))) +def rule_failed(rule, exc_info, settings): + exception('Rule {}'.format(rule.name), exc_info, settings) + + def show_command(new_command, settings): sys.stderr.write('{bold}{command}{reset}\n'.format( command=new_command, diff --git a/thefuck/main.py b/thefuck/main.py index a6c24ee5..2db62103 100644 --- a/thefuck/main.py +++ b/thefuck/main.py @@ -7,7 +7,7 @@ import os import sys from psutil import Process, TimeoutExpired import colorama -from thefuck import logs +from . import logs, conf Command = namedtuple('Command', ('script', 'stdout', 'stderr')) @@ -25,30 +25,6 @@ def setup_user_dir(): return user_dir -def get_settings(user_dir): - """Returns prepared settings module.""" - settings = load_source('settings', - str(user_dir.joinpath('settings.py'))) - settings.__dict__.setdefault('rules', None) - settings.__dict__.setdefault('wait_command', 3) - settings.__dict__.setdefault('require_confirmation', False) - settings.__dict__.setdefault('no_colors', False) - return settings - - -def is_rule_enabled(settings, rule): - """Returns `True` when rule mentioned in `rules` or `rules` - isn't defined. - - """ - if settings.rules is None and rule.enabled_by_default: - return True - elif settings.rules and rule.name in settings.rules: - return True - else: - return False - - def load_rule(rule): """Imports rule module and returns it.""" rule_module = load_source(rule.name[:-3], str(rule)) @@ -66,7 +42,7 @@ def get_rules(user_dir, settings): for rule in sorted(list(bundled)) + list(user): if rule.name != '__init__.py': loaded_rule = load_rule(rule) - if is_rule_enabled(settings, loaded_rule): + if loaded_rule in settings.rules: yield loaded_rule @@ -145,7 +121,7 @@ def is_second_run(command): def main(): colorama.init() user_dir = setup_user_dir() - settings = get_settings(user_dir) + settings = conf.Settings(user_dir) command = get_command(settings, sys.argv) if command: diff --git a/thefuck/utils.py b/thefuck/utils.py index 534a6340..4c87970f 100644 --- a/thefuck/utils.py +++ b/thefuck/utils.py @@ -37,10 +37,7 @@ def wrap_settings(params): def decorator(fn): @wraps(fn) def wrapper(command, settings): - for key, val in params.items(): - if not hasattr(settings, key): - setattr(settings, key, val) - return fn(command, settings) + return fn(command, settings.update(**params)) return wrapper return decorator