Skip to content

Commit

Permalink
A new mechanism for showing "did you mean" help.
Browse files Browse the repository at this point in the history
Makes suggestions for unknown goals and unknown flags.

The old system for suggesting similar flag names was
complex, and didn't handle or display scopes in a
user-helpful way. So for example if the user used a
global flag in goal scope, we would show a suggestion
like: "Unknown flag --foobar, did you mean --foobar".

This new implementation handles that case, and moves
the error message computation and display out of the
options system and into the local_pants_runner.py,
which has enough context to do so.

[ci skip-rust]

[ci skip-build-wheels]
  • Loading branch information
benjyw committed Sep 28, 2020
1 parent 5f46286 commit caf0201
Show file tree
Hide file tree
Showing 17 changed files with 202 additions and 282 deletions.
1 change: 0 additions & 1 deletion 3rdparty/python/BUILD
Expand Up @@ -6,7 +6,6 @@ python_requirements(
module_mapping={
"ansicolors": ["colors"],
"beautifulsoup4": ["bs4"],
"python-Levenshtein": ["Levenshtein"],
"PyYAML": ["yaml"],
"setuptools": ["pkg_resources"],
}
Expand Down
11 changes: 5 additions & 6 deletions 3rdparty/python/constraints.txt
@@ -1,15 +1,15 @@
# Generated by build-support/bin/generate_lockfile.sh on Mon Sep 21 16:22:12 MST 2020
# Generated by build-support/bin/generate_lockfile.sh on Mon Sep 28 09:49:00 PDT 2020
ansicolors==1.1.8
attrs==20.2.0
beautifulsoup4==4.6.3
certifi==2020.6.20
cffi==1.14.3
chardet==3.0.4
cryptography==3.1
cryptography==3.1.1
dataclasses==0.6
fasteners==0.15
idna==2.10
importlib-metadata==1.7.0
importlib-metadata==2.0.0
iniconfig==1.0.1
monotonic==1.5
more-itertools==8.5.0
Expand All @@ -18,7 +18,7 @@ mypy-extensions==0.4.3
packaging==20.4
pathspec==0.8.0
pex==2.1.16
pip==18.1
pip==19.0.3
pluggy==0.13.1
psutil==5.7.0
py==1.9.0
Expand All @@ -27,7 +27,6 @@ pyOpenSSL==19.1.0
pyparsing==2.4.7
pystache==0.5.4
pytest==6.0.2
python-Levenshtein==0.12.0
PyYAML==5.3.1
requests==2.24.0
setproctitle==1.1.10
Expand All @@ -38,4 +37,4 @@ typed-ast==1.4.1
typing-extensions==3.7.4.2
urllib3==1.25.10
www-authenticate==0.9.2
zipp==3.1.0
zipp==3.2.0
1 change: 0 additions & 1 deletion 3rdparty/python/requirements.txt
Expand Up @@ -22,7 +22,6 @@ psutil==5.7.0
pystache==0.5.4
# This should be kept in sync with `pytest.py`.
pytest>=6.0.1,<6.1
python-Levenshtein==0.12.0
PyYAML>=5.3.1,<5.4
requests[security]>=2.20.1
setproctitle==1.1.10
Expand Down
58 changes: 42 additions & 16 deletions src/python/pants/bin/local_pants_runner.py
Expand Up @@ -3,7 +3,7 @@

import logging
import os
from dataclasses import dataclass
from dataclasses import dataclass, replace
from typing import Mapping, Optional, Tuple

from pants.base.build_environment import get_buildroot
Expand All @@ -19,11 +19,13 @@
from pants.engine.internals.session import SessionValues
from pants.engine.unions import UnionMembership
from pants.goal.run_tracker import RunTracker
from pants.help.flag_help_printer import FlagErrorHelpPrinter
from pants.help.help_info_extracter import HelpInfoExtracter
from pants.help.help_printer import HelpPrinter
from pants.init.engine_initializer import EngineInitializer, GraphScheduler, GraphSession
from pants.init.options_initializer import BuildConfigInitializer, OptionsInitializer
from pants.init.specs_calculator import calculate_specs
from pants.option.errors import UnknownFlagsError
from pants.option.options import Options
from pants.option.options_bootstrapper import OptionsBootstrapper
from pants.option.subsystem import Subsystem
Expand Down Expand Up @@ -54,16 +56,22 @@ class LocalPantsRunner:
profile_path: Optional[str]
_run_tracker: RunTracker

@staticmethod
@classmethod
def parse_options(
cls,
options_bootstrapper: OptionsBootstrapper,
) -> Tuple[Options, BuildConfiguration]:
build_config = BuildConfigInitializer.get(options_bootstrapper)
options = OptionsInitializer.create(options_bootstrapper, build_config)
try:
options = OptionsInitializer.create(options_bootstrapper, build_config)
except UnknownFlagsError as err:
cls._handle_unknown_flags(err, options_bootstrapper)
raise
return options, build_config

@staticmethod
@classmethod
def _init_graph_session(
cls,
options_bootstrapper: OptionsBootstrapper,
build_config: BuildConfiguration,
options: Options,
Expand All @@ -75,7 +83,11 @@ def _init_graph_session(
options_bootstrapper, build_config
)

global_scope = options.for_global_scope()
try:
global_scope = options.for_global_scope()
except UnknownFlagsError as err:
cls._handle_unknown_flags(err, options_bootstrapper)
raise
dynamic_ui = global_scope.dynamic_ui if global_scope.v2 else False
use_colors = global_scope.get("colors", True)

Expand All @@ -93,6 +105,16 @@ def _init_graph_session(
),
)

@staticmethod
def _handle_unknown_flags(err: UnknownFlagsError, options_bootstrapper: OptionsBootstrapper):
build_config = BuildConfigInitializer.get(options_bootstrapper)
# We need an options instance in order to get "did you mean" suggestions, but we know
# there are bad flags in the args, so we generate options with no flags.
no_arg_bootstrapper = replace(options_bootstrapper, args=("dummy_first_arg",))
# Note: doesn't validate, so won't fail on the unknown flags.
options = OptionsInitializer.construct_options(no_arg_bootstrapper, build_config)
FlagErrorHelpPrinter(options).handle_unknown_flags(err)

@classmethod
def create(
cls,
Expand All @@ -111,16 +133,7 @@ def create(
"""
build_root = get_buildroot()
global_bootstrap_options = options_bootstrapper.bootstrap_options.for_global_scope()
options, build_config = LocalPantsRunner.parse_options(options_bootstrapper)

# Option values are usually computed lazily on demand,
# but command line options are eagerly computed for validation.
for scope in options.scope_to_flags.keys():
options.for_scope(scope)

# Verify configs.
if global_bootstrap_options.verify_config:
options.verify_configs(options_bootstrapper.config)
options, build_config = cls.parse_options(options_bootstrapper)

union_membership = UnionMembership.from_rules(build_config.union_rules)

Expand All @@ -130,6 +143,19 @@ def create(
options_bootstrapper, build_config, options, scheduler
)

# Option values are usually computed lazily on demand,
# but command line options are eagerly computed for validation.
for scope in options.scope_to_flags.keys():
try:
options.for_scope(scope)
except UnknownFlagsError as err:
cls._handle_unknown_flags(err, options_bootstrapper)
raise

# Verify configs.
if global_bootstrap_options.verify_config:
options.verify_configs(options_bootstrapper.config)

specs = calculate_specs(
options_bootstrapper=options_bootstrapper,
options=options,
Expand Down Expand Up @@ -246,7 +272,7 @@ def run(self, start_time: float) -> ExitCode:
bin_name=global_options.pants_bin_name,
help_request=self.options.help_request,
all_help_info=all_help_info,
use_color=global_options.colors,
color=global_options.colors,
)
return help_printer.print_help()

Expand Down
55 changes: 55 additions & 0 deletions src/python/pants/help/flag_help_printer.py
@@ -0,0 +1,55 @@
# Copyright 2020 Pants project contributors (see CONTRIBUTORS.md).
# Licensed under the Apache License, Version 2.0 (see LICENSE).

import difflib

from pants.engine.unions import UnionMembership
from pants.help.help_info_extracter import HelpInfoExtracter
from pants.help.maybe_color import MaybeColor
from pants.option.errors import UnknownFlagsError
from pants.option.options import Options
from pants.option.scope import GLOBAL_SCOPE


class FlagErrorHelpPrinter(MaybeColor):
"""Prints help related to erroneous command-line flag specification to the console."""

def __init__(self, options: Options):
super().__init__(options.for_global_scope().colors)
self._bin_name = options.for_global_scope().pants_bin_name
self._all_help_info = HelpInfoExtracter.get_all_help_info(
options,
# We only care about the options-related help info, so we pass in
# dummy values for union_membership and consumed_scopes_mapper.
UnionMembership({}),
lambda x: tuple(),
)

def handle_unknown_flags(self, err: UnknownFlagsError):
global_flags = self._all_help_info.scope_to_help_info[GLOBAL_SCOPE].collect_unscoped_flags()
oshi_for_scope = self._all_help_info.scope_to_help_info.get(err.arg_scope)
possibilities = oshi_for_scope.collect_unscoped_flags() if oshi_for_scope else []

if err.arg_scope == GLOBAL_SCOPE:
# We allow all scoped flags for any scope in the global scope position on
# the cmd line (that is, to the left of any goals).
for oshi in self._all_help_info.scope_to_help_info.values():
possibilities.extend(oshi.collect_scoped_flags())

for flag in err.flags:
print(f"Unknown flag {self.maybe_red(flag)} on {err.arg_scope or 'global'} scope")
did_you_mean = difflib.get_close_matches(flag, possibilities)
if err.arg_scope != GLOBAL_SCOPE and flag in global_flags:
# It's a common error to use a global flag in a goal scope, so we special-case it.
print(
f"Did you mean to use the global {self.maybe_cyan(flag)}? "
f"Global options must come before any goals."
)
elif did_you_mean:
print(f"Did you mean {', '.join(self.maybe_cyan(g) for g in did_you_mean)}?")

help_cmd = (
f"{self._bin_name} help"
f"{'' if err.arg_scope == GLOBAL_SCOPE else (' ' + err.arg_scope)}"
)
print(f"Use `{self.maybe_green(help_cmd)}` to get help.")
42 changes: 13 additions & 29 deletions src/python/pants/help/help_formatter.py
Expand Up @@ -5,32 +5,16 @@
from textwrap import wrap
from typing import List, Optional

from colors import cyan, green, magenta, red

from pants.help.help_info_extracter import OptionHelpInfo, OptionScopeHelpInfo, to_help_str
from pants.help.maybe_color import MaybeColor
from pants.option.ranked_value import Rank, RankedValue


class HelpFormatter:
class HelpFormatter(MaybeColor):
def __init__(self, *, show_advanced: bool, show_deprecated: bool, color: bool) -> None:
super().__init__(color=color)
self._show_advanced = show_advanced
self._show_deprecated = show_deprecated
self._color = color

def _maybe_cyan(self, s):
return self._maybe_color(cyan, s)

def _maybe_green(self, s):
return self._maybe_color(green, s)

def _maybe_red(self, s):
return self._maybe_color(red, s)

def _maybe_magenta(self, s):
return self._maybe_color(magenta, s)

def _maybe_color(self, color, s):
return color(s) if self._color else s

def format_options(self, oshi: OptionScopeHelpInfo):
"""Return a help message for the specified options."""
Expand All @@ -42,17 +26,17 @@ def add_option(ohis, *, category=None):
display_scope = f"`{oshi.scope}` {goal_or_subsystem}" if oshi.scope else "Global"
if category:
title = f"{display_scope} {category} options"
lines.append(self._maybe_green(f"{title}\n{'-' * len(title)}"))
lines.append(self.maybe_green(f"{title}\n{'-' * len(title)}"))
else:
# The basic options section gets the description and options scope info.
# No need to repeat those in the advanced section.
title = f"{display_scope} options"
lines.append(self._maybe_green(f"{title}\n{'-' * len(title)}"))
lines.append(self.maybe_green(f"{title}\n{'-' * len(title)}"))
if oshi.description:
lines.append(f"\n{oshi.description}")
lines.append(" ")
config_section = f"[{oshi.scope or 'GLOBAL'}]"
lines.append(f"Config section: {self._maybe_magenta(config_section)}")
lines.append(f"Config section: {self.maybe_magenta(config_section)}")
lines.append(" ")
if not ohis:
lines.append("None available.")
Expand Down Expand Up @@ -84,16 +68,16 @@ def format_value(val: RankedValue, prefix: str, left_padding: str) -> List[str]:
val_lines = [to_help_str(val.value)]
val_lines[0] = f"{prefix}{val_lines[0]}"
val_lines[-1] = f"{val_lines[-1]}{maybe_parens(val.details)}"
val_lines = [self._maybe_cyan(f"{left_padding}{line}") for line in val_lines]
val_lines = [self.maybe_cyan(f"{left_padding}{line}") for line in val_lines]
return val_lines

indent = " "
arg_lines = [f" {self._maybe_magenta(args)}" for args in ohi.display_args]
arg_lines.append(self._maybe_magenta(f" {ohi.env_var}"))
arg_lines.append(self._maybe_magenta(f" {ohi.config_key}"))
arg_lines = [f" {self.maybe_magenta(args)}" for args in ohi.display_args]
arg_lines.append(self.maybe_magenta(f" {ohi.env_var}"))
arg_lines.append(self.maybe_magenta(f" {ohi.config_key}"))
choices = "" if ohi.choices is None else f"one of: [{', '.join(ohi.choices)}]"
choices_lines = [
f"{indent}{' ' if i != 0 else ''}{self._maybe_cyan(s)}"
f"{indent}{' ' if i != 0 else ''}{self.maybe_cyan(s)}"
for i, s in enumerate(wrap(f"{choices}", 96))
]
default_lines = format_value(RankedValue(Rank.HARDCODED, ohi.default), "default: ", indent)
Expand Down Expand Up @@ -127,7 +111,7 @@ def format_value(val: RankedValue, prefix: str, left_padding: str) -> List[str]:
*description_lines,
]
if ohi.deprecated_message:
lines.append(self._maybe_red(f"{indent}{ohi.deprecated_message}."))
lines.append(self.maybe_red(f"{indent}{ohi.deprecated_message}."))
if ohi.removal_hint:
lines.append(self._maybe_red(f"{indent}{ohi.removal_hint}"))
lines.append(self.maybe_red(f"{indent}{ohi.removal_hint}"))
return lines
16 changes: 15 additions & 1 deletion src/python/pants/help/help_info_extracter.py
Expand Up @@ -6,7 +6,7 @@
import json
from dataclasses import dataclass
from enum import Enum
from typing import Any, Callable, Dict, Optional, Tuple, Type, cast
from typing import Any, Callable, Dict, List, Optional, Tuple, Type, cast

from pants.base import deprecated
from pants.engine.goal import GoalSubsystem
Expand Down Expand Up @@ -99,6 +99,20 @@ class OptionScopeHelpInfo:
advanced: Tuple[OptionHelpInfo, ...]
deprecated: Tuple[OptionHelpInfo, ...]

def collect_unscoped_flags(self) -> List[str]:
flags: List[str] = []
for options in (self.basic, self.advanced, self.deprecated):
for ohi in options:
flags.extend(ohi.unscoped_cmd_line_args)
return flags

def collect_scoped_flags(self) -> List[str]:
flags: List[str] = []
for options in (self.basic, self.advanced, self.deprecated):
for ohi in options:
flags.extend(ohi.scoped_cmd_line_args)
return flags


@dataclass(frozen=True)
class GoalHelpInfo:
Expand Down

0 comments on commit caf0201

Please sign in to comment.