diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..7bffe105 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,49 @@ +name: Tests + +on: [push, pull_request] + +env: + PYTHON_LATEST: 3.9 + +jobs: + test: + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9, 3.10-dev] + fail-fast: false + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Cache dependencies + id: cache-deps + uses: actions/cache@v2 + with: + path: | + ${{ env.pythonLocation }}/bin/* + ${{ env.pythonLocation }}/lib/* + ${{ env.pythonLocation }}/scripts/* + key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('setup.py', 'requirements.txt') }} + - name: Install The Fuck with all dependencies + if: steps.cache-deps.outputs.cache-hit != 'true' + run: | + pip install -Ur requirements.txt coveralls + python setup.py develop + - name: Lint + if: matrix.os == 'ubuntu-latest' && matrix.python-version == env.PYTHON_LATEST + run: flake8 + - name: Run tests + if: matrix.os != 'ubuntu-latest' || matrix.python-version != env.PYTHON_LATEST + run: coverage run --source=thefuck,tests -m pytest -v --capture=sys tests + - name: Run tests (including functional) + if: matrix.os == 'ubuntu-latest' && matrix.python-version == env.PYTHON_LATEST + run: coverage run --source=thefuck,tests -m pytest -v --capture=sys tests --enable-functional + - name: Post coverage results + if: matrix.os == 'ubuntu-latest' && matrix.python-version == env.PYTHON_LATEST + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: coveralls --service=github diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index c8876a0c..00000000 --- a/.travis.yml +++ /dev/null @@ -1,51 +0,0 @@ -language: python -sudo: false -os: linux -dist: xenial -matrix: - include: - - python: "nightly" - - python: "3.8-dev" - - python: "3.8" - - python: "3.7-dev" - - python: "3.7" - - python: "3.6-dev" - - python: "3.6" - - python: "3.5" - - 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: - apt: - packages: - - python-commandnotfound - - python3-commandnotfound -before_install: - - if [[ $TRAVIS_OS_NAME == "osx" ]]; then rm -rf /usr/local/include/c++; fi - - if [[ $TRAVIS_OS_NAME == "osx" ]]; then brew update; fi - - if [[ $TRAVIS_OS_NAME == "osx" ]]; then brew unlink python@2; fi - - if [[ $TRAVIS_OS_NAME == "osx" ]]; then brew upgrade python; fi - - if [[ $TRAVIS_OS_NAME == "osx" ]]; then pip3 install virtualenv; fi - - if [[ $TRAVIS_OS_NAME == "osx" ]]; then virtualenv venv -p python3; fi - - if [[ $TRAVIS_OS_NAME == "osx" ]]; then source venv/bin/activate; fi - - pip install -U pip - - pip install -U coveralls -install: - - pip install -Ur requirements.txt - - python setup.py develop - - rm -rf build -script: - - flake8 - - export COVERAGE_PYTHON_VERSION=python-${TRAVIS_PYTHON_VERSION:0:1} - - export RUN_TESTS="coverage run --source=thefuck,tests -m py.test -v --capture=sys tests" - - if [[ $TRAVIS_PYTHON_VERSION == 3.8 && $TRAVIS_OS_NAME != "osx" ]]; then $RUN_TESTS --enable-functional; fi - - if [[ $TRAVIS_PYTHON_VERSION != 3.8 || $TRAVIS_OS_NAME == "osx" ]]; then $RUN_TESTS; fi -after_success: - - if [[ $TRAVIS_PYTHON_VERSION == 3.8 && $TRAVIS_OS_NAME != "osx" ]]; then coveralls; fi diff --git a/README.md b/README.md index bbf6d82c..be3420a1 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# The Fuck [![Version][version-badge]][version-link] [![Build Status][travis-badge]][travis-link] [![Windows Build Status][appveyor-badge]][appveyor-link] [![Coverage][coverage-badge]][coverage-link] [![MIT License][license-badge]](LICENSE.md) +# The Fuck [![Version][version-badge]][version-link] [![Build Status][workflow-badge]][workflow-link] [![Coverage][coverage-badge]][coverage-link] [![MIT License][license-badge]](LICENSE.md) *The Fuck* is a magnificent app, inspired by a [@liamosaur](https://twitter.com/liamosaur/) [tweet](https://twitter.com/liamosaur/status/506975850596536320), @@ -282,6 +282,7 @@ following rules are enabled by default: * `pyenv_no_such_command` – fixes wrong pyenv commands like `pyenv isntall` or `pyenv list`; * `python_command` – prepends `python` when you try to run non-executable/without `./` python script; * `python_execute` – appends missing `.py` when executing Python files; +* `python_module_error` – fixes ModuleNotFoundError by trying to `pip install` that module; * `quotation_marks` – fixes uneven usage of `'` and `"` when containing args'; * `path_from_history` – replaces not found path with similar absolute path from history; * `react_native_command_unrecognized` – fixes unrecognized `react-native` commands; @@ -503,10 +504,8 @@ Project License can be found [here](LICENSE.md). [version-badge]: https://img.shields.io/pypi/v/thefuck.svg?label=version [version-link]: https://pypi.python.org/pypi/thefuck/ -[travis-badge]: https://travis-ci.org/nvbn/thefuck.svg?branch=master -[travis-link]: https://travis-ci.org/nvbn/thefuck -[appveyor-badge]: https://ci.appveyor.com/api/projects/status/1sskj4imj02um0gu/branch/master?svg=true -[appveyor-link]: https://ci.appveyor.com/project/nvbn/thefuck +[workflow-badge]: https://github.com/divykj/thefuck/workflows/Tests/badge.svg +[workflow-link]: https://github.com/divykj/thefuck/actions?query=workflow%3ATests [coverage-badge]: https://img.shields.io/coveralls/nvbn/thefuck.svg [coverage-link]: https://coveralls.io/github/nvbn/thefuck [license-badge]: https://img.shields.io/badge/license-MIT-007EC7.svg diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index 0de834f2..00000000 --- a/appveyor.yml +++ /dev/null @@ -1,23 +0,0 @@ -build: false - -environment: - matrix: - - PYTHON: "C:/Python27" - - PYTHON: "C:/Python35" - - PYTHON: "C:/Python36" - - PYTHON: "C:/Python37" - -init: - - "ECHO %PYTHON%" - - ps: "ls C:/Python*" - -install: - - "curl -fsS -o C:/get-pip.py https://bootstrap.pypa.io/get-pip.py" - - "%PYTHON%/python.exe C:/get-pip.py" - - "%PYTHON%/Scripts/pip.exe install -U setuptools" - - "%PYTHON%/python.exe setup.py develop" - - "%PYTHON%/Scripts/pip.exe install -U -r requirements.txt" - -test_script: - - "%PYTHON%/python.exe -m flake8" - - "%PYTHON%/Scripts/py.test.exe -sv" diff --git a/tests/conftest.py b/tests/conftest.py index 3d097159..964458b6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,6 +7,10 @@ from thefuck.system import Path shells.shell = shells.Generic() +def pytest_configure(config): + config.addinivalue_line("markers", "functional: mark test as functional") + + def pytest_addoption(parser): """Adds `--enable-functional` argument.""" group = parser.getgroup("thefuck") diff --git a/tests/rules/test_go_unknown_command.py b/tests/rules/test_go_unknown_command.py index b2456540..c3cd34b7 100644 --- a/tests/rules/test_go_unknown_command.py +++ b/tests/rules/test_go_unknown_command.py @@ -1,6 +1,7 @@ import pytest from thefuck.rules.go_unknown_command import match, get_new_command from thefuck.types import Command +from thefuck.utils import which @pytest.fixture @@ -17,5 +18,6 @@ def test_not_match(): assert not match(Command('go run', 'go run: no go files listed')) +@pytest.mark.skipif(not which('go'), reason='Skip if go executable not found') def test_get_new_command(build_misspelled_output): assert get_new_command(Command('go bulid', build_misspelled_output)) == 'go build' diff --git a/tests/rules/test_python_module_error.py b/tests/rules/test_python_module_error.py new file mode 100644 index 00000000..838f9561 --- /dev/null +++ b/tests/rules/test_python_module_error.py @@ -0,0 +1,63 @@ +import pytest + +from thefuck.rules.python_module_error import get_new_command, match +from thefuck.types import Command + + +@pytest.fixture +def module_error_output(filename, module_name): + return """Traceback (most recent call last): + File "{0}", line 1, in + import {1} +ModuleNotFoundError: No module named '{1}'""".format( + filename, module_name + ) + + +@pytest.mark.parametrize( + "test", + [ + Command("python hello_world.py", "Hello World"), + Command( + "./hello_world.py", + """Traceback (most recent call last): + File "hello_world.py", line 1, in + pritn("Hello World") +NameError: name 'pritn' is not defined""", + ), + ], +) +def test_not_match(test): + assert not match(test) + + +positive_tests = [ + ( + "python some_script.py", + "some_script.py", + "more_itertools", + "pip install more_itertools && python some_script.py", + ), + ( + "./some_other_script.py", + "some_other_script.py", + "a_module", + "pip install a_module && ./some_other_script.py", + ), +] + + +@pytest.mark.parametrize( + "script, filename, module_name, corrected_script", positive_tests +) +def test_match(script, filename, module_name, corrected_script, module_error_output): + assert match(Command(script, module_error_output)) + + +@pytest.mark.parametrize( + "script, filename, module_name, corrected_script", positive_tests +) +def test_get_new_command( + script, filename, module_name, corrected_script, module_error_output +): + assert get_new_command(Command(script, module_error_output)) == corrected_script diff --git a/tests/test_conf.py b/tests/test_conf.py index 7d0fe4b8..657e4755 100644 --- a/tests/test_conf.py +++ b/tests/test_conf.py @@ -43,7 +43,7 @@ class TestSettingsFromFile(object): assert settings.rules == const.DEFAULT_RULES + ['test'] -@pytest.mark.usefixture('load_source') +@pytest.mark.usefixtures('load_source') class TestSettingsFromEnv(object): def test_from_env(self, os_environ, settings): os_environ.update({'THEFUCK_RULES': 'bash:lisp', diff --git a/tests/test_corrector.py b/tests/test_corrector.py index fb8cb94d..d28819df 100644 --- a/tests/test_corrector.py +++ b/tests/test_corrector.py @@ -8,14 +8,15 @@ from thefuck.types import Command from thefuck.corrector import get_corrected_commands, organize_commands -class TestGetRules(object): - @pytest.fixture - def glob(self, mocker): - results = {} - mocker.patch('thefuck.system.Path.glob', - new_callable=lambda: lambda *_: results.pop('value', [])) - return lambda value: results.update({'value': value}) +@pytest.fixture +def glob(mocker): + results = {} + mocker.patch('thefuck.system.Path.glob', + new_callable=lambda: lambda *_: results.pop('value', [])) + return lambda value: results.update({'value': value}) + +class TestGetRules(object): @pytest.fixture(autouse=True) def load_source(self, monkeypatch): monkeypatch.setattr('thefuck.types.load_source', @@ -39,6 +40,14 @@ class TestGetRules(object): self._compare_names(rules, loaded_rules) +def test_get_rules_rule_exception(mocker, glob): + load_source = mocker.patch('thefuck.types.load_source', + side_effect=ImportError("No module named foo...")) + glob([Path('git.py')]) + assert not corrector.get_rules() + load_source.assert_called_once_with('git', 'git.py') + + def test_get_corrected_commands(mocker): command = Command('test', 'test') rules = [Rule(match=lambda _: False), diff --git a/tests/test_types.py b/tests/test_types.py index 4d0f0f70..3a43cded 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -45,6 +45,12 @@ class TestCorrectedCommand(object): class TestRule(object): + def test_from_path_rule_exception(self, mocker): + load_source = mocker.patch('thefuck.types.load_source', + side_effect=ImportError("No module named foo...")) + assert Rule.from_path(Path('git.py')) is None + load_source.assert_called_once_with('git', 'git.py') + def test_from_path(self, mocker): match = object() get_new_command = object() @@ -60,20 +66,22 @@ class TestRule(object): == Rule('bash', match, get_new_command, priority=900)) load_source.assert_called_once_with('bash', rule_path) - @pytest.mark.parametrize('rules, exclude_rules, rule, is_enabled', [ - (const.DEFAULT_RULES, [], Rule('git', enabled_by_default=True), True), - (const.DEFAULT_RULES, [], Rule('git', enabled_by_default=False), False), - ([], [], Rule('git', enabled_by_default=False), False), - ([], [], Rule('git', enabled_by_default=True), False), - (const.DEFAULT_RULES + ['git'], [], Rule('git', enabled_by_default=False), True), - (['git'], [], Rule('git', enabled_by_default=False), True), - (const.DEFAULT_RULES, ['git'], Rule('git', enabled_by_default=True), False), - (const.DEFAULT_RULES, ['git'], Rule('git', enabled_by_default=False), False), - ([], ['git'], Rule('git', enabled_by_default=True), False), - ([], ['git'], Rule('git', enabled_by_default=False), False)]) - def test_is_enabled(self, settings, rules, exclude_rules, rule, is_enabled): - settings.update(rules=rules, - exclude_rules=exclude_rules) + def test_from_path_excluded_rule(self, mocker, settings): + load_source = mocker.patch('thefuck.types.load_source') + settings.update(exclude_rules=['git']) + rule_path = os.path.join(os.sep, 'rules', 'git.py') + assert Rule.from_path(Path(rule_path)) is None + assert not load_source.called + + @pytest.mark.parametrize('rules, rule, is_enabled', [ + (const.DEFAULT_RULES, Rule('git', enabled_by_default=True), True), + (const.DEFAULT_RULES, Rule('git', enabled_by_default=False), False), + ([], Rule('git', enabled_by_default=False), False), + ([], Rule('git', enabled_by_default=True), False), + (const.DEFAULT_RULES + ['git'], Rule('git', enabled_by_default=False), True), + (['git'], Rule('git', enabled_by_default=False), True)]) + def test_is_enabled(self, settings, rules, rule, is_enabled): + settings.update(rules=rules) assert rule.is_enabled == is_enabled def test_isnt_match(self): @@ -131,6 +139,7 @@ class TestCommand(object): env=os_environ) @pytest.mark.parametrize('script, result', [ + ([], None), ([''], None), (['', ''], None), (['ls', '-la'], 'ls -la'), diff --git a/thefuck/corrector.py b/thefuck/corrector.py index 89d21ebf..fdd46983 100644 --- a/thefuck/corrector.py +++ b/thefuck/corrector.py @@ -15,7 +15,7 @@ def get_loaded_rules(rules_paths): for path in rules_paths: if path.name != '__init__.py': rule = Rule.from_path(path) - if rule.is_enabled: + if rule and rule.is_enabled: yield rule diff --git a/thefuck/entrypoints/fix_command.py b/thefuck/entrypoints/fix_command.py index e3e3b01f..6946653e 100644 --- a/thefuck/entrypoints/fix_command.py +++ b/thefuck/entrypoints/fix_command.py @@ -23,6 +23,7 @@ def _get_raw_command(known_args): diff = SequenceMatcher(a=alias, b=command).ratio() if diff < const.DIFF_WITH_ALIAS or command in executables: return [command] + return [] def fix_command(known_args): diff --git a/thefuck/rules/python_module_error.py b/thefuck/rules/python_module_error.py new file mode 100644 index 00000000..4696d63b --- /dev/null +++ b/thefuck/rules/python_module_error.py @@ -0,0 +1,13 @@ +import re +from thefuck.shells import shell + +MISSING_MODULE = r"ModuleNotFoundError: No module named '([^']+)'" + + +def match(command): + return "ModuleNotFoundError: No module named '" in command.output + + +def get_new_command(command): + missing_module = re.findall(MISSING_MODULE, command.output)[0] + return shell.and_("pip install {}".format(missing_module), command.script) diff --git a/thefuck/types.py b/thefuck/types.py index 5d8c2cc7..96e6ace6 100644 --- a/thefuck/types.py +++ b/thefuck/types.py @@ -136,9 +136,16 @@ class Rule(object): """ name = path.name[:-3] + if name in settings.exclude_rules: + logs.debug(u'Ignoring excluded rule: {}'.format(name)) + return with logs.debug_time(u'Importing rule: {};'.format(name)): - rule_module = load_source(name, str(path)) - priority = getattr(rule_module, 'priority', DEFAULT_PRIORITY) + try: + rule_module = load_source(name, str(path)) + except Exception: + logs.exception(u"Rule {} failed to load".format(name), sys.exc_info()) + return + priority = getattr(rule_module, 'priority', DEFAULT_PRIORITY) return cls(name, rule_module.match, rule_module.get_new_command, getattr(rule_module, 'enabled_by_default', True), @@ -153,14 +160,11 @@ class Rule(object): :rtype: bool """ - if self.name in settings.exclude_rules: - return False - elif self.name in settings.rules: - return True - elif self.enabled_by_default and ALL_ENABLED in settings.rules: - return True - else: - return False + return ( + self.name in settings.rules + or self.enabled_by_default + and ALL_ENABLED in settings.rules + ) def is_match(self, command): """Returns `True` if rule matches the command.