Skip to content

Commit 94a7c99

Browse files
authored
Merge pull request #494 from python-cmd2/matches_sorted
Matches sorted
2 parents bc559df + a78e931 commit 94a7c99

File tree

8 files changed

+131
-32
lines changed

8 files changed

+131
-32
lines changed

CHANGELOG.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22
* Bug Fixes
33
* Fixed bug where ``preparse`` wasn't getting called
44
* Enhancements
5-
* Improved implementation of lifecycle hooks to to support a plugin
5+
* Improved implementation of lifecycle hooks to support a plugin
66
framework, see ``docs/hooks.rst`` for details.
77
* New dependency on ``attrs`` third party module
8+
* Added ``matches_sorted`` member to support custom sorting of tab-completion matches
89
* Deprecations
910
* Deprecated the following hook methods, see ``hooks.rst`` for full details:
1011
* ``cmd2.Cmd.preparse()`` - equivilent functionality available
@@ -14,6 +15,10 @@
1415
* ``cmd2.Cmd.postparsing_postcmd()`` - equivilent functionality available
1516
via ``cmd2.Cmd.register_postcmd_hook()``
1617

18+
## 0.8.9 (August TBD, 2018)
19+
* Bug Fixes
20+
* Fixed extra slash that could print when tab completing users on Windows
21+
1722
## 0.9.3 (July 12, 2018)
1823
* Bug Fixes
1924
* Fixed bug when StatementParser ``__init__()`` was called with ``terminators`` equal to ``None``

README.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ Main Features
3434
- Ability to load commands at startup from an initialization script
3535
- Settable environment parameters
3636
- Parsing commands with arguments using `argparse`, including support for sub-commands
37-
- Sub-menu support via the ``AddSubmenu`` decorator
3837
- Unicode character support
3938
- Good tab-completion of commands, sub-commands, file system paths, and shell commands
4039
- Support for Python 3.4+ on Windows, macOS, and Linux
@@ -58,7 +57,7 @@ pip install -U cmd2
5857
```
5958

6059
cmd2 works with Python 3.4+ on Windows, macOS, and Linux. It is pure Python code with
61-
the only 3rd-party dependencies being on [attrs](https://github.com/python-attrs/attrs),
60+
the only 3rd-party dependencies being on [attrs](https://github.com/python-attrs/attrs),
6261
[colorama](https://github.com/tartley/colorama), and [pyperclip](https://github.com/asweigart/pyperclip).
6362
Windows has an additional dependency on [pyreadline](https://pypi.python.org/pypi/pyreadline). Non-Windows platforms
6463
have an additional dependency on [wcwidth](https://pypi.python.org/pypi/wcwidth). Finally, Python

cmd2/argparse_completer.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,7 @@ def _format_completions(self, action, completions: List[Union[str, CompletionIte
492492

493493
self._cmd2_app.completion_header = header
494494
self._cmd2_app.display_matches = completions_with_desc
495+
self._cmd2_app.matches_sorted = True
495496

496497
return completions
497498

cmd2/cmd2.py

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,7 @@ def __init__(self, completekey: str='tab', stdin=None, stdout=None, persistent_h
369369
except AttributeError:
370370
pass
371371

372-
# initialize plugin system
372+
# initialize plugin system
373373
# needs to be done before we call __init__(0)
374374
self._initialize_plugin_system()
375375

@@ -482,11 +482,11 @@ def __init__(self, completekey: str='tab', stdin=None, stdout=None, persistent_h
482482
# in reset_completion_defaults() and it is up to completer functions to set them before returning results.
483483
############################################################################################################
484484

485-
# If true and a single match is returned to complete(), then a space will be appended
485+
# If True and a single match is returned to complete(), then a space will be appended
486486
# if the match appears at the end of the line
487487
self.allow_appended_space = True
488488

489-
# If true and a single match is returned to complete(), then a closing quote
489+
# If True and a single match is returned to complete(), then a closing quote
490490
# will be added if there is an unmatched opening quote
491491
self.allow_closing_quote = True
492492

@@ -504,6 +504,10 @@ def __init__(self, completekey: str='tab', stdin=None, stdout=None, persistent_h
504504
# quote matches that are completed in a delimited fashion
505505
self.matches_delimited = False
506506

507+
# Set to True before returning matches to complete() in cases where matches are sorted with custom ordering.
508+
# If False, then complete() will sort the matches alphabetically before they are displayed.
509+
self.matches_sorted = False
510+
507511
# Set the pager(s) for use with the ppaged() method for displaying output using a pager
508512
if sys.platform.startswith('win'):
509513
self.pager = self.pager_chop = 'more'
@@ -678,6 +682,7 @@ def reset_completion_defaults(self) -> None:
678682
self.completion_header = ''
679683
self.display_matches = []
680684
self.matches_delimited = False
685+
self.matches_sorted = False
681686

682687
if rl_type == RlType.GNU:
683688
readline.set_completion_display_matches_hook(self._display_matches_gnu_readline)
@@ -994,12 +999,15 @@ def complete_users():
994999
users = []
9951000

9961001
# Windows lacks the pwd module so we can't get a list of users.
997-
# Instead we will add a slash once the user enters text that
1002+
# Instead we will return a result once the user enters text that
9981003
# resolves to an existing home directory.
9991004
if sys.platform.startswith('win'):
10001005
expanded_path = os.path.expanduser(text)
10011006
if os.path.isdir(expanded_path):
1002-
users.append(text + os.path.sep)
1007+
user = text
1008+
if add_trailing_sep_if_dir:
1009+
user += os.path.sep
1010+
users.append(user)
10031011
else:
10041012
import pwd
10051013

@@ -1083,6 +1091,10 @@ def complete_users():
10831091
self.allow_appended_space = False
10841092
self.allow_closing_quote = False
10851093

1094+
# Sort the matches before any trailing slashes are added
1095+
matches = utils.alphabetical_sort(matches)
1096+
self.matches_sorted = True
1097+
10861098
# Build display_matches and add a slash to directories
10871099
for index, cur_match in enumerate(matches):
10881100

@@ -1446,11 +1458,8 @@ def complete(self, text: str, state: int) -> Optional[str]:
14461458
if self.completion_matches:
14471459

14481460
# Eliminate duplicates
1449-
matches_set = set(self.completion_matches)
1450-
self.completion_matches = list(matches_set)
1451-
1452-
display_matches_set = set(self.display_matches)
1453-
self.display_matches = list(display_matches_set)
1461+
self.completion_matches = utils.remove_duplicates(self.completion_matches)
1462+
self.display_matches = utils.remove_duplicates(self.display_matches)
14541463

14551464
if not self.display_matches:
14561465
# Since self.display_matches is empty, set it to self.completion_matches
@@ -1521,10 +1530,11 @@ def complete(self, text: str, state: int) -> Optional[str]:
15211530

15221531
self.completion_matches[0] += str_to_append
15231532

1524-
# Otherwise sort matches
1525-
elif self.completion_matches:
1526-
self.completion_matches.sort()
1527-
self.display_matches.sort()
1533+
# Sort matches alphabetically if they haven't already been sorted
1534+
if not self.matches_sorted:
1535+
self.completion_matches = utils.alphabetical_sort(self.completion_matches)
1536+
self.display_matches = utils.alphabetical_sort(self.display_matches)
1537+
self.matches_sorted = True
15281538

15291539
try:
15301540
return self.completion_matches[state]
@@ -2270,7 +2280,7 @@ def do_unalias(self, arglist: List[str]) -> None:
22702280

22712281
else:
22722282
# Get rid of duplicates
2273-
arglist = list(set(arglist))
2283+
arglist = utils.remove_duplicates(arglist)
22742284

22752285
for cur_arg in arglist:
22762286
if cur_arg in self.aliases:
@@ -2315,12 +2325,10 @@ def _help_menu(self, verbose: bool=False) -> None:
23152325
"""Show a list of commands which help can be displayed for.
23162326
"""
23172327
# Get a sorted list of help topics
2318-
help_topics = self.get_help_topics()
2319-
help_topics.sort()
2328+
help_topics = utils.alphabetical_sort(self.get_help_topics())
23202329

23212330
# Get a sorted list of visible command names
2322-
visible_commands = self.get_visible_commands()
2323-
visible_commands.sort()
2331+
visible_commands = utils.alphabetical_sort(self.get_visible_commands())
23242332

23252333
cmds_doc = []
23262334
cmds_undoc = []

cmd2/utils.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import collections
66
import os
77
from typing import Any, List, Optional, Union
8+
import unicodedata
89

910
from . import constants
1011

@@ -110,7 +111,7 @@ def which(editor: str) -> Optional[str]:
110111

111112

112113
def is_text_file(file_path: str) -> bool:
113-
"""Returns if a file contains only ASCII or UTF-8 encoded text
114+
"""Returns if a file contains only ASCII or UTF-8 encoded text.
114115
115116
:param file_path: path to the file being checked
116117
:return: True if the file is a text file, False if it is binary.
@@ -144,3 +145,34 @@ def is_text_file(file_path: str) -> bool:
144145
pass
145146

146147
return valid_text_file
148+
149+
150+
def remove_duplicates(list_to_prune: List) -> List:
151+
"""Removes duplicates from a list while preserving order of the items.
152+
153+
:param list_to_prune: the list being pruned of duplicates
154+
:return: The pruned list
155+
"""
156+
temp_dict = collections.OrderedDict()
157+
for item in list_to_prune:
158+
temp_dict[item] = None
159+
160+
return list(temp_dict.keys())
161+
162+
163+
def norm_fold(astr: str) -> str:
164+
"""Normalize and casefold Unicode strings for saner comparisons.
165+
166+
:param astr: input unicode string
167+
:return: a normalized and case-folded version of the input string
168+
"""
169+
return unicodedata.normalize('NFC', astr).casefold()
170+
171+
172+
def alphabetical_sort(list_to_sort: List[str]) -> List[str]:
173+
"""Sorts a list of strings alphabetically.
174+
175+
:param list_to_sort: the list being sorted
176+
:return: the sorted list
177+
"""
178+
return sorted(list_to_sort, key=norm_fold)

examples/tab_autocompletion.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ def __init__(self):
3838
static_list_directors = ['J. J. Abrams', 'Irvin Kershner', 'George Lucas', 'Richard Marquand',
3939
'Rian Johnson', 'Gareth Edwards']
4040
USER_MOVIE_LIBRARY = ['ROGUE1', 'SW_EP04', 'SW_EP05']
41-
MOVIE_DATABASE_IDS = ['SW_EP01', 'SW_EP02', 'SW_EP03', 'ROGUE1', 'SW_EP04',
41+
MOVIE_DATABASE_IDS = ['SW_EP1', 'SW_EP02', 'SW_EP03', 'ROGUE1', 'SW_EP04',
4242
'SW_EP05', 'SW_EP06', 'SW_EP07', 'SW_EP08', 'SW_EP09']
4343
MOVIE_DATABASE = {'SW_EP04': {'title': 'Star Wars: Episode IV - A New Hope',
4444
'rating': 'PG',
@@ -52,13 +52,13 @@ def __init__(self):
5252
'actor': ['Mark Hamill', 'Harrison Ford', 'Carrie Fisher',
5353
'Alec Guinness', 'Peter Mayhew', 'Anthony Daniels']
5454
},
55-
'SW_EP06': {'title': 'Star Wars: Episode IV - A New Hope',
55+
'SW_EP06': {'title': 'Star Wars: Episode VI - Return of the Jedi',
5656
'rating': 'PG',
5757
'director': ['Richard Marquand'],
5858
'actor': ['Mark Hamill', 'Harrison Ford', 'Carrie Fisher',
5959
'Alec Guinness', 'Peter Mayhew', 'Anthony Daniels']
6060
},
61-
'SW_EP01': {'title': 'Star Wars: Episode I - The Phantom Menace',
61+
'SW_EP1': {'title': 'Star Wars: Episode I - The Phantom Menace',
6262
'rating': 'PG',
6363
'director': ['George Lucas'],
6464
'actor': ['Liam Neeson', 'Ewan McGregor', 'Natalie Portman', 'Jake Lloyd']
@@ -113,8 +113,10 @@ def instance_query_movie_ids(self) -> List[str]:
113113
"""Demonstrates showing tabular hinting of tab completion information"""
114114
completions_with_desc = []
115115

116-
for movie_id, movie_entry in self.MOVIE_DATABASE.items():
117-
completions_with_desc.append(argparse_completer.CompletionItem(movie_id, movie_entry['title']))
116+
for movie_id in self.MOVIE_DATABASE_IDS:
117+
if movie_id in self.MOVIE_DATABASE:
118+
movie_entry = self.MOVIE_DATABASE[movie_id]
119+
completions_with_desc.append(argparse_completer.CompletionItem(movie_id, movie_entry['title']))
118120

119121
return completions_with_desc
120122

tests/test_completion.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
import pytest
1616
import cmd2
17+
from cmd2 import utils
1718
from .conftest import complete_tester, StdOut
1819
from examples.subcommands import SubcommandsExample
1920

@@ -251,7 +252,7 @@ def test_path_completion_multiple(cmd2_app, request):
251252
endidx = len(line)
252253
begidx = endidx - len(text)
253254

254-
matches = sorted(cmd2_app.path_complete(text, line, begidx, endidx))
255+
matches = cmd2_app.path_complete(text, line, begidx, endidx)
255256
expected = [text + 'cript.py', text + 'cript.txt', text + 'cripts' + os.path.sep]
256257
assert matches == expected
257258

@@ -408,9 +409,8 @@ def test_delimiter_completion(cmd2_app):
408409
cmd2_app.delimiter_complete(text, line, begidx, endidx, delimited_strs, '/')
409410

410411
# Remove duplicates from display_matches and sort it. This is typically done in complete().
411-
display_set = set(cmd2_app.display_matches)
412-
display_list = list(display_set)
413-
display_list.sort()
412+
display_list = utils.remove_duplicates(cmd2_app.display_matches)
413+
display_list = utils.alphabetical_sort(display_list)
414414

415415
assert display_list == ['other user', 'user']
416416

tests/test_utils.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# coding=utf-8
2+
"""
3+
Unit testing for cmd2/utils.py module.
4+
5+
Copyright 2018 Todd Leonhardt <[email protected]>
6+
Released under MIT license, see LICENSE file
7+
"""
8+
from colorama import Fore
9+
import cmd2.utils as cu
10+
11+
HELLO_WORLD = 'Hello, world!'
12+
13+
14+
def test_strip_ansi():
15+
base_str = HELLO_WORLD
16+
ansi_str = Fore.GREEN + base_str + Fore.RESET
17+
assert base_str != ansi_str
18+
assert base_str == cu.strip_ansi(ansi_str)
19+
20+
def test_strip_quotes_no_quotes():
21+
base_str = HELLO_WORLD
22+
stripped = cu.strip_quotes(base_str)
23+
assert base_str == stripped
24+
25+
def test_strip_quotes_with_quotes():
26+
base_str = '"' + HELLO_WORLD + '"'
27+
stripped = cu.strip_quotes(base_str)
28+
assert stripped == HELLO_WORLD
29+
30+
def test_remove_duplicates_no_duplicates():
31+
no_dups = [5, 4, 3, 2, 1]
32+
assert cu.remove_duplicates(no_dups) == no_dups
33+
34+
def test_remove_duplicates_with_duplicates():
35+
duplicates = [1, 1, 2, 3, 9, 9, 7, 8]
36+
assert cu.remove_duplicates(duplicates) == [1, 2, 3, 9, 7, 8]
37+
38+
def test_unicode_normalization():
39+
s1 = 'café'
40+
s2 = 'cafe\u0301'
41+
assert s1 != s2
42+
assert cu.norm_fold(s1) == cu.norm_fold(s2)
43+
44+
def test_unicode_casefold():
45+
micro = 'µ'
46+
micro_cf = micro.casefold()
47+
assert micro != micro_cf
48+
assert cu.norm_fold(micro) == cu.norm_fold(micro_cf)
49+
50+
def test_alphabetical_sort():
51+
my_list = ['café', 'µ', 'A' , 'micro', 'unity', 'cafeteria']
52+
assert cu.alphabetical_sort(my_list) == ['A', 'cafeteria', 'café', 'micro', 'unity', 'µ']

0 commit comments

Comments
 (0)