Skip to content

Add the ability to autocorrect a user's command #4193

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 10 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions news/4193.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Add ability to suggest commands and also to autocorrect mistyped commands.

A new "autocorrect" configuration key is now supported. It can be set to a
numerical value. If it is set, pip will wait for the specified seconds and then
autocorrect your command and continue, if a replacement is similar enough.
65 changes: 52 additions & 13 deletions pip/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@

import locale
import logging
import os
import optparse
import warnings

import os
import sys
import time
import warnings

# 2016-06-17 [email protected]: urllib3 1.14 added optional support for socks,
# but if invoked (i.e. imported), it will issue a warning to stderr if socks
Expand Down Expand Up @@ -40,12 +40,12 @@
else:
securetransport.inject_into_urllib3()

from pip.exceptions import CommandError, PipError
from pip.exceptions import CommandError, ConfigurationError, PipError
from pip.utils import get_installed_distributions, get_prog
from pip.utils import deprecation
from pip.vcs import git, mercurial, subversion, bazaar # noqa
from pip.baseparser import ConfigOptionParser, UpdatingDefaultsHelpFormatter
from pip.commands import get_summaries, get_similar_commands
from pip.commands import get_summaries, get_closest_command
from pip.commands import commands_dict
from pip._vendor.requests.packages.urllib3.exceptions import (
InsecureRequestWarning,
Expand Down Expand Up @@ -199,18 +199,57 @@ def parseopts(args):
# the subcommand name
cmd_name = args_else[0]

# all the args without the subcommand
cmd_args = args[:]
cmd_args.remove(cmd_name)

# Perform diagnosis of what the user typed
if cmd_name not in commands_dict:
guess = get_similar_commands(cmd_name)
# these were manually chosen
suggest_cutoff = 0.6
autocorrect_cutoff = 0.8

msg = ['unknown command "%s"' % cmd_name]
if guess:
msg.append('maybe you meant "%s"' % guess)
# Determine if user wants autocorrect.
try:
autocorrect_delay = parser.config.get_value("global.autocorrect")
except ConfigurationError:
autocorrect_delay = None

raise CommandError(' - '.join(msg))
try:
autocorrect_delay = float(autocorrect_delay)
except ValueError:
raise ConfigurationError(
"autocorrect needs to be a numerical value"
)

# all the args without the subcommand
cmd_args = args[:]
cmd_args.remove(cmd_name)
guess, score = get_closest_command(cmd_name)

# Decide what message has to be shown to user
msg = 'pip does not have a command "%s"' % cmd_name
if score > suggest_cutoff:
msg += ' - did you mean "%s"?' % guess

allowed_to_autocorrect = (
autocorrect_delay is not None and score > autocorrect_cutoff
)
if not allowed_to_autocorrect:
raise CommandError(msg)

# Show a message to the user that pip is assuming.
msg = (
'You called a pip command named "%s" which does not exist.\n'
'Assuming you meant "%s", pip will continue in %.1f seconds...'
)

logger.warning(msg, cmd_name, guess, autocorrect_delay)
try:
time.sleep(autocorrect_delay)
except KeyboardInterrupt:
logger.critical('Operation cancelled by user')
sys.exit(pip.status_codes.ERROR)

# Assume and proceed.
cmd_name = guess

return cmd_name, cmd_args

Expand Down
63 changes: 49 additions & 14 deletions pip/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
"""
from __future__ import absolute_import

from difflib import SequenceMatcher

from pip.commands.completion import CompletionCommand
from pip.commands.configuration import ConfigurationCommand
from pip.commands.download import DownloadCommand
Expand Down Expand Up @@ -48,20 +50,6 @@ def get_summaries(ordered=True):
yield (name, command_class.summary)


def get_similar_commands(name):
"""Command name auto-correct."""
from difflib import get_close_matches

name = name.lower()

close_commands = get_close_matches(name, commands_dict.keys())

if close_commands:
return close_commands[0]
else:
return False


def _sort_commands(cmddict, order):
def keyfn(key):
try:
Expand All @@ -71,3 +59,50 @@ def keyfn(key):
return 0xff

return sorted(cmddict.items(), key=keyfn)


def _get_closest_match(word, possibilities):
"""Get the closest match of word in possibilities.

Returns a tuple of (name, similarity) where possibility has the
highest similarity, where 0 <= similarity <= 1.
Returns (None, 0) as a fallback, if no possibility matches.

If more than one possibilities have the highest similarity, the first
matched is returned.
"""
guess, best_score = None, 0

matcher = SequenceMatcher()
matcher.set_seq2(word)

for trial in possibilities:
matcher.set_seq1(trial)

# These are upper limits.
if matcher.real_quick_ratio() < best_score:
continue
if matcher.quick_ratio() < best_score:
continue

# Select the first best match
score = matcher.ratio()
if score > best_score:
guess = trial
best_score = score

return guess, best_score


def get_closest_command(name):
"""Command name auto-correction

If there are any commands with a similarity greater than cutoff, returns
(command_name, similarity) of the command_name with highest similarity.

If there is no such command, returns (None, 0).

If more than one commands have the highest similarity, the alphabetically
first is returned.
"""
return _get_closest_match(name.lower(), sorted(commands_dict.keys()))
7 changes: 4 additions & 3 deletions pip/commands/help.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class HelpCommand(Command):
ignore_require_venv = True

def run(self, options, args):
from pip.commands import commands_dict, get_similar_commands
from pip.commands import commands_dict, get_closest_command

try:
# 'pip help' with no args is handled by pip.__init__.parseopt()
Expand All @@ -22,10 +22,11 @@ def run(self, options, args):
return SUCCESS

if cmd_name not in commands_dict:
guess = get_similar_commands(cmd_name)
guess, score = get_closest_command(cmd_name)
suggest_cut_off = 0.6

msg = ['unknown command "%s"' % cmd_name]
if guess:
if guess and score > suggest_cut_off:
msg.append('maybe you meant "%s"' % guess)

raise CommandError(' - '.join(msg))
Expand Down
1 change: 0 additions & 1 deletion pip/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
)
from pip.utils import ensure_dir, enum


logger = logging.getLogger(__name__)


Expand Down