diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e26ac79..324d870 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,11 +1,12 @@ - repo: git@github.com:pre-commit/pre-commit-hooks.git - sha: 6f2b0a27e5b9047c6c067fb3d575ba323d572793 + sha: 35548254adb636ce52b5574eb1904b8c795b673e hooks: - id: autopep8-wrapper args: - --in-place - --aggressive - --aggressive + - --max-line-length=131 - id: check-added-large-files - id: check-ast - id: check-case-conflict @@ -16,10 +17,10 @@ - id: fix-encoding-pragma - id: flake8 args: - - --max-complexity=10 - - --max-line-length=130 - - --ignore=E126,E128,E731 - - --exclude=bin/autojump_argparse.py + - --max-complexity=10 + - --max-line-length=131 + - --ignore=E126,E128,E731 + - --exclude=bin/autojump_argparse.py - id: requirements-txt-fixer - id: trailing-whitespace - repo: git@github.com:asottile/reorder_python_imports.git diff --git a/Makefile b/Makefile index 5466f00..3675eed 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ VERSION = $(shell grep -oE "[0-9]+\.[0-9]+\.[0-9]+" bin/autojump) TAGNAME = release-v$(VERSION) -.PHONY: docs install uninstall lint tar test +.PHONY: clean docs install uninstall pre-commit lint tar test install: ./install.py @@ -39,9 +39,15 @@ tar: sha1sum autojump_v$(VERSION).tar.gz test: pre-commit - @find . -type f -iname '*.py[co]' -delete - tox + @tox + +test-xfail: pre-commit + @tox -- --runxfail test-fast: pre-commit + @tox -e py27 + +clean: @find . -type f -iname '*.py[co]' -delete - tox -e py27 + @find . -type d -iname '__pycache__' -delete + @rm -fr .tox diff --git a/README.md b/README.md index 5af4374..182193a 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ INSTALLATION ### REQUIREMENTS -- Python v2.6+ +- Python v2.6+ except v3.2 - Supported shells: - bash v4.0+ - zsh @@ -148,7 +148,7 @@ maintained by William Ting. More contributors can be found in `AUTHORS`. COPYRIGHT --------- -Copyright © 2012 Free Software Foundation, Inc. License GPLv3+: GNU GPL +Copyright © 2016 Free Software Foundation, Inc. License GPLv3+: GNU GPL version 3 or later . This is free software: you are free to change and redistribute it. There is NO WARRANTY, to the extent permitted by law. diff --git a/bin/autojump b/bin/autojump index 2be7f7f..bf1443d 100755 --- a/bin/autojump +++ b/bin/autojump @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- """ Copyright © 2008-2012 Joel Schaerer - Copyright © 2012-2014 William Ting + Copyright © 2012-2016 William Ting * This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by @@ -18,16 +18,13 @@ along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. """ - from __future__ import print_function -from difflib import SequenceMatcher from itertools import chain from math import sqrt from operator import attrgetter from operator import itemgetter import os -import re import sys if sys.version_info[0] == 3: @@ -38,13 +35,21 @@ else: from itertools import ifilter from itertools import imap +# Vendorized argparse for Python 2.6 support from autojump_argparse import ArgumentParser +# autojump is not a standard python package but rather installed as a mixed +# shell + Python app with no outside dependencies (except Python). As a +# consequence we use relative imports and depend on file prefixes to prevent +# module conflicts. from autojump_data import dictify from autojump_data import entriefy from autojump_data import Entry from autojump_data import load from autojump_data import save +from autojump_match import match_anywhere +from autojump_match import match_consecutive +from autojump_match import match_fuzzy from autojump_utils import first from autojump_utils import get_pwd from autojump_utils import get_tab_entry_info @@ -60,7 +65,7 @@ from autojump_utils import sanitize from autojump_utils import take from autojump_utils import unico -VERSION = '22.3.2' +VERSION = '22.3.3' FUZZY_MATCH_THRESHOLD = 0.6 TAB_ENTRIES_COUNT = 9 TAB_SEPARATOR = '__' @@ -221,111 +226,6 @@ def handle_tab_completion(needle, entries): TAB_SEPARATOR) -def match_anywhere(needles, haystack, ignore_case=False): - """ - Matches needles anywhere in the path as long as they're in the same (but - not necessary consecutive) order. - - For example: - needles = ['foo', 'baz'] - regex needle = r'.*foo.*baz.*' - haystack = [ - (path="/foo/bar/baz", weight=10), - (path="/baz/foo/bar", weight=10), - (path="/foo/baz", weight=10)] - - result = [ - (path="/moo/foo/baz", weight=10), - (path="/foo/baz", weight=10)] - """ - regex_needle = '.*' + '.*'.join(needles).replace('\\', '\\\\') + '.*' - regex_flags = re.IGNORECASE | re.UNICODE if ignore_case else re.UNICODE - found = lambda haystack: re.search( - regex_needle, - haystack.path, - flags=regex_flags) - return ifilter(found, haystack) - - -def match_consecutive(needles, haystack, ignore_case=False): - """ - Matches consecutive needles at the end of a path. - - For example: - needles = ['foo', 'baz'] - haystack = [ - (path="/foo/bar/baz", weight=10), - (path="/foo/baz/moo", weight=10), - (path="/moo/foo/baz", weight=10), - (path="/foo/baz", weight=10)] - - regex_needle = re.compile(r''' - foo # needle #1 - [^/]* # all characters except os.sep zero or more times - / # os.sep - [^/]* # all characters except os.sep zero or more times - baz # needle #2 - [^/]* # all characters except os.sep zero or more times - $ # end of string - ''') - - result = [ - (path="/moo/foo/baz", weight=10), - (path="/foo/baz", weight=10)] - """ - # The normal \\ separator needs to be escaped again for use in regex. - sep = '\\\\' if is_windows() else os.sep - regex_no_sep = '[^' + sep + ']*' - regex_no_sep_end = regex_no_sep + '$' - regex_one_sep = regex_no_sep + sep + regex_no_sep - # can't use compiled regex because of flags - regex_needle = regex_one_sep.join(needles).replace('\\', '\\\\') + regex_no_sep_end # noqa - regex_flags = re.IGNORECASE | re.UNICODE if ignore_case else re.UNICODE - found = lambda entry: re.search( - regex_needle, - entry.path, - flags=regex_flags) - return ifilter(found, haystack) - - -def match_fuzzy(needles, haystack, ignore_case=False): - """ - Performs an approximate match with the last needle against the end of - every path past an acceptable threshold (FUZZY_MATCH_THRESHOLD). - - For example: - needles = ['foo', 'bar'] - haystack = [ - (path="/foo/bar/baz", weight=11), - (path="/foo/baz/moo", weight=10), - (path="/moo/foo/baz", weight=10), - (path="/foo/baz", weight=10), - (path="/foo/bar", weight=10)] - - result = [ - (path="/foo/bar/baz", weight=11), - (path="/moo/foo/baz", weight=10), - (path="/foo/baz", weight=10), - (path="/foo/bar", weight=10)] - - This is a weak heuristic and used as a last resort to find matches. - """ - end_dir = lambda path: last(os.path.split(path)) - if ignore_case: - needle = last(needles).lower() - match_percent = lambda entry: SequenceMatcher( - a=needle, - b=end_dir(entry.path.lower())).ratio() - else: - needle = last(needles) - match_percent = lambda entry: SequenceMatcher( - a=needle, - b=end_dir(entry.path)).ratio() - meets_threshold = lambda entry: match_percent(entry) >= \ - FUZZY_MATCH_THRESHOLD - return ifilter(meets_threshold, haystack) - - def purge_missing_paths(entries): """Remove non-existent paths from a list of entries.""" exists = lambda entry: os.path.exists(entry.path) diff --git a/bin/autojump_match.py b/bin/autojump_match.py new file mode 100644 index 0000000..75f9aca --- /dev/null +++ b/bin/autojump_match.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +import os +import re +from difflib import SequenceMatcher + +from autojump_utils import is_python3 +from autojump_utils import last + + +if is_python3(): # pragma: no cover + ifilter = filter + imap = map + os.getcwdu = os.getcwd +else: + from itertools import ifilter + from itertools import imap + + +def match_anywhere(needles, haystack, ignore_case=False): + """ + Matches needles anywhere in the path as long as they're in the same (but + not necessary consecutive) order. + + For example: + needles = ['foo', 'baz'] + regex needle = r'.*foo.*baz.*' + haystack = [ + (path='/foo/bar/baz', weight=10), + (path='/baz/foo/bar', weight=10), + (path='/foo/baz', weight=10), + ] + + result = [ + (path='/moo/foo/baz', weight=10), + (path='/foo/baz', weight=10), + ] + """ + regex_needle = '.*' + '.*'.join(imap(re.escape, needles)) + '.*' + regex_flags = re.IGNORECASE | re.UNICODE if ignore_case else re.UNICODE + found = lambda haystack: re.search( + regex_needle, + haystack.path, + flags=regex_flags, + ) + return ifilter(found, haystack) + + +def match_consecutive(needles, haystack, ignore_case=False): + """ + Matches consecutive needles at the end of a path. + + For example: + needles = ['foo', 'baz'] + haystack = [ + (path='/foo/bar/baz', weight=10), + (path='/foo/baz/moo', weight=10), + (path='/moo/foo/baz', weight=10), + (path='/foo/baz', weight=10), + ] + + # We can't actually use re.compile because of re.UNICODE + regex_needle = re.compile(r''' + foo # needle #1 + [^/]* # all characters except os.sep zero or more times + / # os.sep + [^/]* # all characters except os.sep zero or more times + baz # needle #2 + [^/]* # all characters except os.sep zero or more times + $ # end of string + ''') + + result = [ + (path='/moo/foo/baz', weight=10), + (path='/foo/baz', weight=10), + ] + """ + regex_no_sep = '[^' + os.sep + ']*' + regex_no_sep_end = regex_no_sep + '$' + regex_one_sep = regex_no_sep + os.sep + regex_no_sep + regex_needle = regex_one_sep.join(imap(re.escape, needles)) + regex_no_sep_end + regex_flags = re.IGNORECASE | re.UNICODE if ignore_case else re.UNICODE + found = lambda entry: re.search( + regex_needle, + entry.path, + flags=regex_flags, + ) + return ifilter(found, haystack) + + +def match_fuzzy(needles, haystack, ignore_case=False, threshold=0.6): + """ + Performs an approximate match with the last needle against the end of + every path past an acceptable threshold. + + For example: + needles = ['foo', 'bar'] + haystack = [ + (path='/foo/bar/baz', weight=11), + (path='/foo/baz/moo', weight=10), + (path='/moo/foo/baz', weight=10), + (path='/foo/baz', weight=10), + (path='/foo/bar', weight=10), + ] + + result = [ + (path='/foo/bar/baz', weight=11), + (path='/moo/foo/baz', weight=10), + (path='/foo/baz', weight=10), + (path='/foo/bar', weight=10), + ] + + This is a weak heuristic and used as a last resort to find matches. + """ + end_dir = lambda path: last(os.path.split(path)) + if ignore_case: + needle = last(needles).lower() + match_percent = lambda entry: SequenceMatcher( + a=needle, + b=end_dir(entry.path.lower())).ratio() + else: + needle = last(needles) + match_percent = lambda entry: SequenceMatcher( + a=needle, + b=end_dir(entry.path)).ratio() + meets_threshold = lambda entry: match_percent(entry) >= threshold + return ifilter(meets_threshold, haystack) diff --git a/docs/autojump.1 b/docs/autojump.1 index 188194f..2e185ce 100644 --- a/docs/autojump.1 +++ b/docs/autojump.1 @@ -127,7 +127,7 @@ maintained by William Ting. More contributors can be found in \f[C]AUTHORS\f[]. .SS COPYRIGHT .PP -Copyright © 2012 Free Software Foundation, Inc. +Copyright © 2016 Free Software Foundation, Inc. License GPLv3+: GNU GPL version 3 or later . This is free software: you are free to change and redistribute it. diff --git a/docs/body.md b/docs/body.md index 9d635b4..91a169e 100644 --- a/docs/body.md +++ b/docs/body.md @@ -28,7 +28,7 @@ William Ting. More contributors can be found in `AUTHORS`. COPYRIGHT --------- -Copyright © 2012 Free Software Foundation, Inc. License GPLv3+: GNU GPL version +Copyright © 2016 Free Software Foundation, Inc. License GPLv3+: GNU GPL version 3 or later . This is free software: you are free to change and redistribute it. There is NO WARRANTY, to the extent permitted by law. diff --git a/docs/install.md b/docs/install.md index 1ad7d66..895ff29 100644 --- a/docs/install.md +++ b/docs/install.md @@ -2,7 +2,7 @@ ### REQUIREMENTS -- Python v2.6+ +- Python v2.6+ except v3.2 - Supported shells: - bash v4.0+ - zsh @@ -41,7 +41,7 @@ MacPorts also available: ## Windows -Windows support is enabled by [clink](https://code.google.com/p/clink/) which +Windows support is enabled by [clink](https://mridgers.github.io/clink/) which should be installed prior to installing autojump. ### MANUAL diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index 053148f..0000000 --- a/requirements-dev.txt +++ /dev/null @@ -1 +0,0 @@ -tox diff --git a/tests/autojump_data_test.py b/tests/integration/__init__.py similarity index 100% rename from tests/autojump_data_test.py rename to tests/integration/__init__.py diff --git a/tests/autojump_test.py b/tests/unit/__init__.py similarity index 100% rename from tests/autojump_test.py rename to tests/unit/__init__.py diff --git a/tests/unit/autojump_match_test.py b/tests/unit/autojump_match_test.py new file mode 100644 index 0000000..bd02ff5 --- /dev/null +++ b/tests/unit/autojump_match_test.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +import os +import sys + +import pytest + +sys.path.append(os.path.join(os.getcwd(), 'bin')) # noqa +from autojump_data import Entry +from autojump_match import match_anywhere +from autojump_match import match_consecutive + + +class TestMatchAnywhere(object): + + entry1 = Entry('/foo/bar/baz', 10) + entry2 = Entry('/baz/foo/bar', 10) + entry3 = Entry('/foo/baz', 10) + entry4 = Entry('/中/zhong/国/guo', 10) + entry5 = Entry('/is\'t/this/a/b*tchin/edge/case?', 10) + win_entry1 = Entry('C:\\foo\\bar\\baz', 10) + win_entry2 = Entry('D:\Program Files (x86)\GIMP', 10) + win_entry3 = Entry('C:\Windows\System32', 10) + + @pytest.fixture + def haystack(self): + return [ + self.entry1, + self.entry2, + self.entry3, + self.entry4, + self.entry5, + ] + + @pytest.fixture + def windows_haystack(self): + return [self.win_entry1, self.win_entry2, self.win_entry3] + + def test_single_needle(self, haystack): + assert list(match_anywhere(['bar'], haystack)) == [self.entry1, self.entry2] + + def test_consecutive(self, haystack): + assert list(match_anywhere(['foo', 'bar'], haystack)) \ + == [self.entry1, self.entry2] + assert list(match_anywhere(['bar', 'foo'], haystack)) == [] + + def test_skip(self, haystack): + assert list(match_anywhere(['baz', 'bar'], haystack)) == [self.entry2] + assert list(match_anywhere(['中', '国'], haystack)) == [self.entry4] + + def test_ignore_case(self, haystack): + assert list(match_anywhere(['bAz', 'bAR'], haystack, ignore_case=True)) \ + == [self.entry2] + + def test_backslashes_for_windows_paths(self, windows_haystack): + # https://github.com/wting/autojump/issues/281 + assert list(match_anywhere(['foo', 'baz'], windows_haystack)) \ + == [self.win_entry1] + assert list(match_anywhere(['program', 'gimp'], windows_haystack, True)) \ + == [self.win_entry2] + assert list(match_anywhere(['win', '32'], windows_haystack, True)) \ + == [self.win_entry3] + + def test_wildcard_in_needle(self, haystack): + # https://github.com/wting/autojump/issues/402 + assert list(match_anywhere(['*', 'this'], haystack)) == [] + assert list(match_anywhere(['this', '*'], haystack)) == [self.entry5] + + +class TestMatchConsecutive(object): + + entry1 = Entry('/foo/bar/baz', 10) + entry2 = Entry('/baz/foo/bar', 10) + entry3 = Entry('/foo/baz', 10) + entry4 = Entry('/中/zhong/国/guo', 10) + entry5 = Entry('/日/本', 10) + entry6 = Entry('/is\'t/this/a/b*tchin/edge/case?', 10) + win_entry1 = Entry('C:\Foo\Bar\Baz', 10) + win_entry2 = Entry('D:\Program Files (x86)\GIMP', 10) + win_entry3 = Entry('C:\Windows\System32', 10) + + @pytest.fixture + def haystack(self): + return [ + self.entry1, + self.entry2, + self.entry3, + self.entry4, + self.entry5, + ] + + @pytest.fixture + def windows_haystack(self): + return [self.win_entry1, self.win_entry2, self.win_entry3] + + def test_single_needle(self, haystack): + assert list(match_consecutive(['baz'], haystack)) == [self.entry1, self.entry3] + assert list(match_consecutive(['本'], haystack)) == [self.entry5] + + def test_consecutive(self, haystack): + assert list(match_consecutive(['bar', 'baz'], haystack)) == [self.entry1] + assert list(match_consecutive(['foo', 'bar'], haystack)) == [self.entry2] + assert list(match_consecutive(['国', 'guo'], haystack)) == [self.entry4] + assert list(match_consecutive(['bar', 'foo'], haystack)) == [] + + def test_ignore_case(self, haystack): + assert list(match_consecutive(['FoO', 'bAR'], haystack, ignore_case=True)) \ + == [self.entry2] + + def test_windows_ignore_case(self, windows_haystack): + assert list(match_consecutive(['gimp'], windows_haystack, True)) == [self.win_entry2] + + @pytest.mark.xfail(reason='https://github.com/wting/autojump/issues/418') + def test_backslashes_for_windows_paths(self, windows_haystack): + assert list(match_consecutive(['program', 'gimp'], windows_haystack, True)) \ + == [self.win_entry2] + + @pytest.mark.xfail(reason='https://github.com/wting/autojump/issues/418') + def test_foo_bar_baz(self, windows_haystack): + assert list(match_consecutive(['bar', 'baz'], windows_haystack, ignore_case=True)) \ + == [self.win_entry1] + + @pytest.mark.xfail(reason='https://github.com/wting/autojump/issues/402') + def test_thing(self, windows_haystack): + assert list(match_consecutive(['win', '32'], windows_haystack, True)) \ + == [self.win_entry3] + + @pytest.mark.xfail(reason='https://github.com/wting/autojump/issues/402') + def test_wildcard_in_needle(self, haystack): + assert list(match_consecutive(['*', 'this'], haystack)) == [] + assert list(match_consecutive(['*', 'edge', 'case'], haystack)) == [self.entry6] diff --git a/tests/autojump_utils_test.py b/tests/unit/autojump_utils_test.py similarity index 84% rename from tests/autojump_utils_test.py rename to tests/unit/autojump_utils_test.py index fd71a20..07f67b3 100644 --- a/tests/autojump_utils_test.py +++ b/tests/unit/autojump_utils_test.py @@ -6,20 +6,20 @@ import sys import mock import pytest -sys.path.append(os.path.join(os.getcwd(), 'bin')) -import autojump_utils # noqa -from autojump_utils import encode_local # noqa -from autojump_utils import first # noqa -from autojump_utils import get_tab_entry_info # noqa -from autojump_utils import has_uppercase # noqa -from autojump_utils import in_bash # noqa -from autojump_utils import is_python3 # noqa -from autojump_utils import last # noqa -from autojump_utils import sanitize # noqa -from autojump_utils import second # noqa -from autojump_utils import surround_quotes # noqa -from autojump_utils import take # noqa -from autojump_utils import unico # noqa +sys.path.append(os.path.join(os.getcwd(), 'bin')) # noqa +import autojump_utils +from autojump_utils import encode_local +from autojump_utils import first +from autojump_utils import get_tab_entry_info +from autojump_utils import has_uppercase +from autojump_utils import in_bash +from autojump_utils import is_python3 +from autojump_utils import last +from autojump_utils import sanitize +from autojump_utils import second +from autojump_utils import surround_quotes +from autojump_utils import take +from autojump_utils import unico if is_python3(): diff --git a/tox.ini b/tox.ini index 1dfb82f..8a21b97 100644 --- a/tox.ini +++ b/tox.ini @@ -2,13 +2,13 @@ envlist = py26, py27, - py32, py33, - py34 + py34, + py35 # ignore missing setup.py skipsdist = True -[testenv:py] +[testenv] setenv = PYTHONDONTWRITEBYTECODE = 1 deps = @@ -16,24 +16,17 @@ deps = coverage ipdb ipython - pytest + pytest >= 2.9 commands = - coverage run --source=bin/ -m pytest -vv -rxs --tb native -s --durations 10 --strict {posargs:tests} + coverage run --source=bin/ --omit=bin/autojump_argparse.py -m \ + py.test -vv -rxs --tb native -s --strict {posargs:tests} coverage report -m -[testenv:flake8] -deps = - flake8 - pyflakes - pep8 - mccabe -commands = - flake8 . [testenv:pre-commit] deps = - pre-commit -command = + pre-commit>=0.7.0 +commands = pre-commit {posargs} [pytest]