From 71f1f4224b267711bd9cc372a2ec69902f423a01 Mon Sep 17 00:00:00 2001 From: nvbn Date: Wed, 8 Apr 2015 18:15:49 +0200 Subject: [PATCH] Initial commit --- .gitignore | 60 +++++++++++++++++++++++++ README.md | 56 ++++++++++++++++++++++++ requirements.txt | 1 + setup.cfg | 3 ++ setup.py | 26 +++++++++++ tests/rules/test_git_push.py | 24 ++++++++++ tests/rules/test_sudo.py | 12 +++++ tests/test_main.py | 81 ++++++++++++++++++++++++++++++++++ thefuck/main.py | 85 ++++++++++++++++++++++++++++++++++++ thefuck/rules/git_push.py | 8 ++++ thefuck/rules/sudo.py | 6 +++ 11 files changed, 362 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 requirements.txt create mode 100644 setup.cfg create mode 100644 setup.py create mode 100644 tests/rules/test_git_push.py create mode 100644 tests/rules/test_sudo.py create mode 100644 tests/test_main.py create mode 100644 thefuck/main.py create mode 100644 thefuck/rules/git_push.py create mode 100644 thefuck/rules/sudo.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..60fbf5d1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,60 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover + +# Translations +*.mo +*.pot + +# Django stuff: +*.log + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +.env +.idea diff --git a/README.md b/README.md new file mode 100644 index 00000000..21a6f2ba --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# The Fuck + +Magnificent app which corrects your previous console command. + +Few examples: + +```bash +➜ apt-get install vim +E: Could not open lock file /var/lib/dpkg/lock - open (13: Permission denied) +E: Unable to lock the administration directory (/var/lib/dpkg/), are you root? + +➜ fuck +[sudo] password for nvbn: +Reading package lists... Done +... + +➜ git push +fatal: The current branch master has no upstream branch. +To push the current branch and set the remote as upstream, use + + git push --set-upstream origin master + + +➜ fuck +Counting objects: 9, done. +... +``` + +## Installation + +Install `The Fuck`: + +```bash +sudo pip3 install thefuck +``` + +And add to `.bashrc` or `.zshrc`: + +```bash +alias fuck='$(thefuck $(fc -ln -1))' +``` + +## Developing + +Install `The Fuck` for development: + +```bash +pip3 install -r requirements.txt +python3 setup.py develop +``` + +Run tests: + +```bash +py.test +``` diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..e079f8a6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +pytest diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..01bb9544 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,3 @@ +[egg_info] +tag_build = dev +tag_svn_revision = true diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..6f638e9b --- /dev/null +++ b/setup.py @@ -0,0 +1,26 @@ +from setuptools import setup, find_packages + + +version = '1' + +setup(name='thefuck', + version=version, + description="", + long_description="""\ +""", + classifiers=[], # Get strings from http://pypi.python.org/pypi?%3Aaction=list_classifiers + keywords='', + author='', + author_email='', + url='', + license='', + packages=find_packages(exclude=['ez_setup', 'examples', 'tests']), + include_package_data=True, + zip_safe=False, + install_requires=[ + # -*- Extra requirements: -*- + ], + entry_points={'console_scripts': [ + 'thefuck = thefuck.main:main', + ]}, + ) diff --git a/tests/rules/test_git_push.py b/tests/rules/test_git_push.py new file mode 100644 index 00000000..d452b028 --- /dev/null +++ b/tests/rules/test_git_push.py @@ -0,0 +1,24 @@ +import pytest +from thefuck.main import Command +from thefuck.rules.git_push import match, get_new_command + + +@pytest.fixture +def stderr(): + return '''fatal: The current branch master has no upstream branch. +To push the current branch and set the remote as upstream, use + + git push --set-upstream origin master + +''' + + +def test_match(stderr): + assert match(Command('git push master', '', stderr)) + assert not match(Command('git push master', '', '')) + assert not match(Command('ls', '', stderr)) + + +def test_get_new_command(stderr): + assert get_new_command(Command('', '', stderr))\ + == "git push --set-upstream origin master" diff --git a/tests/rules/test_sudo.py b/tests/rules/test_sudo.py new file mode 100644 index 00000000..e9d90126 --- /dev/null +++ b/tests/rules/test_sudo.py @@ -0,0 +1,12 @@ +from thefuck.main import Command +from thefuck.rules.sudo import match, get_new_command + + +def test_match(): + assert match(Command('', '', 'Permission denied')) + assert match(Command('', '', 'permission denied')) + assert not match(Command('', '', '')) + + +def test_get_new_command(): + assert get_new_command(Command('ls', '', '')) == 'sudo ls' diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 00000000..2e7c8bbe --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,81 @@ +from unittest.mock import patch, Mock +from subprocess import PIPE +from pathlib import PosixPath, Path +from thefuck import main + + +def test_setup_user_dir(): + with patch('thefuck.main.Path.is_dir', return_value=False), \ + patch('thefuck.main.Path.mkdir') as mkdir, \ + patch('thefuck.main.Path.touch') as touch: + main.setup_user_dir() + assert mkdir.call_count == 2 + assert touch.call_count == 1 + with patch('thefuck.main.Path.is_dir', return_value=True), \ + patch('thefuck.main.Path.mkdir') as mkdir, \ + patch('thefuck.main.Path.touch') as touch: + main.setup_user_dir() + assert mkdir.call_count == 0 + assert touch.call_count == 0 + + +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(main.Settings(None), Path('bash.py')) + assert main.is_rule_enabled(main.Settings(['bash']), Path('bash.py')) + assert not main.is_rule_enabled(main.Settings(['bash']), Path('lisp.py')) + + +def test_load_rule(): + match = object() + get_new_command = object() + with patch('thefuck.main.load_source', + return_value=Mock( + match=match, + get_new_command=get_new_command)) as load_source: + assert main.load_rule(Path('/rules/bash.py')) == main.Rule(match, get_new_command) + load_source.assert_called_once_with('bash', '/rules/bash.py') + + +def test_get_rules(): + with patch('thefuck.main.Path.glob') as glob, \ + patch('thefuck.main.load_source', + lambda x, _: Mock(match=x, get_new_command=x)): + glob.return_value = [PosixPath('bash.py'), PosixPath('lisp.py')] + assert main.get_rules( + Path('~'), + main.Settings(None)) == [main.Rule('bash', 'bash'), + main.Rule('lisp', 'lisp'), + main.Rule('bash', 'bash'), + main.Rule('lisp', 'lisp')] + assert main.get_rules( + Path('~'), + main.Settings(['bash'])) == [main.Rule('bash', 'bash'), + main.Rule('bash', 'bash')] + + +def test_get_command(): + with patch('thefuck.main.Popen') as Popen: + Popen.return_value.stdout.read.return_value = b'stdout' + Popen.return_value.stderr.read.return_value = b'stderr' + assert main.get_command(['thefuck', 'apt-get', 'search', 'vim']) \ + == main.Command('apt-get search vim', 'stdout', 'stderr') + Popen.assert_called_once_with('apt-get search vim', + shell=True, + stdout=PIPE, + stderr=PIPE) + + +def test_get_matched_rule(): + rules = [main.Rule(lambda x: x.script == 'cd ..', None), + main.Rule(lambda _: False, None)] + assert main.get_matched_rule(main.Command('ls', '', ''), + rules) is None + assert main.get_matched_rule(main.Command('cd ..', '', ''), + rules) == rules[0] diff --git a/thefuck/main.py b/thefuck/main.py new file mode 100644 index 00000000..d73cf424 --- /dev/null +++ b/thefuck/main.py @@ -0,0 +1,85 @@ +from collections import namedtuple +from imp import load_source +from pathlib import Path +from os.path import expanduser +from subprocess import Popen, PIPE +import sys + + +Command = namedtuple('Command', ('script', 'stdout', 'stderr')) +Settings = namedtuple('Settings', ('rules',)) +Rule = namedtuple('Rule', ('match', 'get_new_command')) + + +def setup_user_dir() -> Path: + """Returns user config dir, create it when it doesn't exists.""" + user_dir = Path(expanduser('~/.thefuck')) + if not user_dir.is_dir(): + user_dir.mkdir() + user_dir.joinpath('rules').mkdir() + user_dir.joinpath('settings.py').touch() + return user_dir + + +def get_settings(user_dir: Path) -> Settings: + """Returns prepared settings module.""" + settings = load_source('settings', + str(user_dir.joinpath('settings.py'))) + return Settings(getattr(settings, 'rules', None)) + + +def is_rule_enabled(settings: Settings, rule: Path) -> bool: + """Returns `True` when rule mentioned in `rules` or `rules` + isn't defined. + + """ + return settings.rules is None or rule.name[:-3] in settings.rules + + +def load_rule(rule: Path) -> Rule: + """Imports rule module and returns it.""" + rule_module = load_source(rule.name[:-3], str(rule)) + return Rule(rule_module.match, rule_module.get_new_command) + + +def get_rules(user_dir: Path, settings: Settings) -> [Rule]: + """Returns all enabled rules.""" + bundled = Path(__file__).parent\ + .joinpath('rules')\ + .glob('*.py') + user = user_dir.joinpath('rules').glob('*.py') + return [load_rule(rule) for rule in list(bundled) + list(user) + if is_rule_enabled(settings, rule)] + + +def get_command(args: [str]) -> Command: + """Creates command from `args` and executes it.""" + script = ' '.join(args[1:]) + result = Popen(script, shell=True, stdout=PIPE, stderr=PIPE) + return Command(script, result.stdout.read().decode(), + result.stderr.read().decode()) + + +def get_matched_rule(command: Command, rules: [Rule]) -> Rule: + """Returns first matched rule for command.""" + for rule in rules: + if rule.match(command): + return rule + + +def run_rule(rule: Rule, command: Command): + """Runs command from rule for passed command.""" + new_command = rule.get_new_command(command) + print(new_command) + + +def main(): + command = get_command(sys.argv) + user_dir = setup_user_dir() + settings = get_settings(user_dir) + rules = get_rules(user_dir, settings) + matched_rule = get_matched_rule(command, rules) + if matched_rule: + run_rule(matched_rule, command) + else: + print('echo No fuck given') diff --git a/thefuck/rules/git_push.py b/thefuck/rules/git_push.py new file mode 100644 index 00000000..48bbc70f --- /dev/null +++ b/thefuck/rules/git_push.py @@ -0,0 +1,8 @@ +def match(command): + return ('git' in command.script + and 'push' in command.script + and 'set-upstream' in command.stderr) + + +def get_new_command(command): + return command.stderr.split('\n')[-3].strip() diff --git a/thefuck/rules/sudo.py b/thefuck/rules/sudo.py new file mode 100644 index 00000000..9989286e --- /dev/null +++ b/thefuck/rules/sudo.py @@ -0,0 +1,6 @@ +def match(command): + return 'permission denied' in command.stderr.lower() + + +def get_new_command(command): + return 'sudo {}'.format(command.script)