forked from pantsbuild/pants
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add
[cli.alias]
config section for command line alias support. (pan…
…tsbuild#13228) This allows you to run pants with commands that are easier to remember, even if there are long or many options involved. The following two invocations are equivalent, given the pants configuration below: ``` $ ./pants all-changed fmt $ ./pants --changed-since=HEAD --changed-dependees=transitive fmt ``` `pants.toml`: ``` [cli.alias] all-changed = "--changed-since=HEAD --changed-dependees=transitive" ``` # Rust tests and lints will be skipped. Delete if not intended. [ci skip-rust] # Building wheels and fs_util will be skipped. Delete if not intended. [ci skip-build-wheels]
- Loading branch information
1 parent
50cc728
commit 5239864
Showing
8 changed files
with
400 additions
and
36 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,129 @@ | ||
# Copyright 2021 Pants project contributors (see CONTRIBUTORS.md). | ||
# Licensed under the Apache License, Version 2.0 (see LICENSE). | ||
|
||
from __future__ import annotations | ||
|
||
import logging | ||
import re | ||
import shlex | ||
from dataclasses import dataclass, field | ||
from itertools import chain | ||
from typing import Generator | ||
|
||
from pants.option.errors import OptionsError | ||
from pants.option.scope import ScopeInfo | ||
from pants.option.subsystem import Subsystem | ||
from pants.util.frozendict import FrozenDict | ||
|
||
logger = logging.getLogger(__name__) | ||
|
||
|
||
class CliAliasError(OptionsError): | ||
pass | ||
|
||
|
||
class CliAliasCycleError(CliAliasError): | ||
pass | ||
|
||
|
||
class CliAliasInvalidError(CliAliasError): | ||
pass | ||
|
||
|
||
class CliOptions(Subsystem): | ||
options_scope = "cli" | ||
help = "Options for configuring CLI behavior, such as command line aliases." | ||
|
||
@staticmethod | ||
def register_options(register): | ||
register( | ||
"--alias", | ||
type=dict, | ||
default={}, | ||
help=( | ||
"Register command line aliases.\nExample:\n\n" | ||
" [cli.alias]\n" | ||
' green = "fmt lint check"\n' | ||
' all-changed = "--changed-since=HEAD --changed-dependees=transitive"\n' | ||
"\n" | ||
"This would allow you to run `./pants green all-changed`, which is shorthand for " | ||
"`./pants fmt lint check --changed-since=HEAD --changed-dependees=transitive`.\n\n" | ||
"Notice: this option must be placed in a config file (e.g. `pants.toml` or " | ||
"`pantsrc`) to have any effect." | ||
), | ||
) | ||
|
||
|
||
@dataclass(frozen=True) | ||
class CliAlias: | ||
definitions: FrozenDict[str, tuple[str, ...]] = field(default_factory=FrozenDict) | ||
|
||
def __post_init__(self): | ||
valid_alias_re = re.compile(r"\w(\w|-)*\w$", re.IGNORECASE) | ||
for alias in self.definitions.keys(): | ||
if not re.match(valid_alias_re, alias): | ||
raise CliAliasInvalidError( | ||
f"Invalid alias in `[cli].alias` option: {alias!r}. May only contain alpha " | ||
"numerical letters and the separators `-` and `_`, and may not begin/end " | ||
"with a `-`." | ||
) | ||
|
||
@classmethod | ||
def from_dict(cls, aliases: dict[str, str]) -> CliAlias: | ||
definitions = {key: tuple(shlex.split(value)) for key, value in aliases.items()} | ||
|
||
def expand( | ||
definition: tuple[str, ...], *trail: str | ||
) -> Generator[tuple[str, ...], None, None]: | ||
for arg in definition: | ||
if arg not in definitions: | ||
yield (arg,) | ||
else: | ||
if arg in trail: | ||
raise CliAliasCycleError( | ||
"CLI alias cycle detected in `[cli].alias` option: " | ||
+ " -> ".join([arg, *trail]) | ||
) | ||
yield from expand(definitions[arg], arg, *trail) | ||
|
||
return cls( | ||
FrozenDict( | ||
{ | ||
alias: tuple(chain.from_iterable(expand(definition))) | ||
for alias, definition in definitions.items() | ||
} | ||
) | ||
) | ||
|
||
def check_name_conflicts(self, known_scopes: dict[str, ScopeInfo]) -> None: | ||
for alias in self.definitions.keys(): | ||
scope = known_scopes.get(alias) | ||
if scope: | ||
raise CliAliasInvalidError( | ||
f"Invalid alias in `[cli].alias` option: {alias!r}. This is already a " | ||
"registered " + ("goal." if scope.is_goal else "subsystem.") | ||
) | ||
|
||
def expand_args(self, args: tuple[str, ...]) -> tuple[str, ...]: | ||
if not self.definitions: | ||
return args | ||
return tuple(self._do_expand_args(args)) | ||
|
||
def _do_expand_args(self, args: tuple[str, ...]) -> Generator[str, None, None]: | ||
args_iter = iter(args) | ||
for arg in args_iter: | ||
if arg == "--": | ||
# Do not expand pass through arguments. | ||
yield arg | ||
yield from args_iter | ||
return | ||
|
||
expanded = self.maybe_expand(arg) | ||
if expanded: | ||
logger.debug(f"Expanded [cli.alias].{arg} => {' '.join(expanded)}") | ||
yield from expanded | ||
else: | ||
yield arg | ||
|
||
def maybe_expand(self, arg: str) -> tuple[str, ...] | None: | ||
return self.definitions.get(arg) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,151 @@ | ||
# Copyright 2021 Pants project contributors (see CONTRIBUTORS.md). | ||
# Licensed under the Apache License, Version 2.0 (see LICENSE). | ||
|
||
from __future__ import annotations | ||
|
||
from typing import ContextManager | ||
|
||
import pytest | ||
|
||
from pants.option.alias import CliAlias, CliAliasCycleError, CliAliasInvalidError | ||
from pants.option.scope import ScopeInfo | ||
from pants.testutil.pytest_util import no_exception | ||
from pants.util.frozendict import FrozenDict | ||
|
||
|
||
def test_maybe_nothing() -> None: | ||
cli_alias = CliAlias() | ||
assert cli_alias.maybe_expand("arg") is None | ||
|
||
|
||
@pytest.mark.parametrize( | ||
"alias, expanded", | ||
[ | ||
("--arg1", ("--arg1",)), | ||
("--arg1 --arg2", ("--arg1", "--arg2")), | ||
("--arg=value --option", ("--arg=value", "--option")), | ||
("--arg=value --option flag", ("--arg=value", "--option", "flag")), | ||
("--arg 'quoted value'", ("--arg", "quoted value")), | ||
], | ||
) | ||
def test_maybe_expand_alias(alias: str, expanded: tuple[str, ...] | None) -> None: | ||
cli_alias = CliAlias.from_dict( | ||
{ | ||
"alias": alias, | ||
} | ||
) | ||
assert cli_alias.maybe_expand("alias") == expanded | ||
|
||
|
||
@pytest.mark.parametrize( | ||
"args, expanded", | ||
[ | ||
( | ||
("some", "alias", "target"), | ||
("some", "--flag", "goal", "target"), | ||
), | ||
( | ||
# Don't touch pass through args. | ||
("some", "--", "alias", "target"), | ||
("some", "--", "alias", "target"), | ||
), | ||
], | ||
) | ||
def test_expand_args(args: tuple[str, ...], expanded: tuple[str, ...]) -> None: | ||
cli_alias = CliAlias.from_dict( | ||
{ | ||
"alias": "--flag goal", | ||
} | ||
) | ||
assert cli_alias.expand_args(args) == expanded | ||
|
||
|
||
def test_no_expand_when_no_aliases() -> None: | ||
args = ("./pants",) | ||
cli_alias = CliAlias() | ||
assert cli_alias.expand_args(args) is args | ||
|
||
|
||
@pytest.mark.parametrize( | ||
"alias, definitions", | ||
[ | ||
( | ||
{ | ||
"basic": "goal", | ||
"nested": "--option=advanced basic", | ||
}, | ||
{ | ||
"basic": ("goal",), | ||
"nested": ( | ||
"--option=advanced", | ||
"goal", | ||
), | ||
}, | ||
), | ||
( | ||
{ | ||
"multi-nested": "deep nested", | ||
"basic": "goal", | ||
"nested": "--option=advanced basic", | ||
}, | ||
{ | ||
"multi-nested": ("deep", "--option=advanced", "goal"), | ||
"basic": ("goal",), | ||
"nested": ( | ||
"--option=advanced", | ||
"goal", | ||
), | ||
}, | ||
), | ||
( | ||
{ | ||
"cycle": "other-alias", | ||
"other-alias": "cycle", | ||
}, | ||
pytest.raises( | ||
CliAliasCycleError, | ||
match=( | ||
r"CLI alias cycle detected in `\[cli\]\.alias` option: " | ||
r"other-alias -> cycle -> other-alias" | ||
), | ||
), | ||
), | ||
], | ||
) | ||
def test_nested_alias(alias, definitions: dict | ContextManager) -> None: | ||
expect: ContextManager = no_exception() if isinstance(definitions, dict) else definitions | ||
with expect: | ||
cli_alias = CliAlias.from_dict(alias) | ||
if isinstance(definitions, dict): | ||
assert cli_alias.definitions == FrozenDict(definitions) | ||
|
||
|
||
@pytest.mark.parametrize( | ||
"alias", | ||
[ | ||
# Check that we do not allow any alias that may resemble a valid option/spec. | ||
"dir/spec", | ||
"file.name", | ||
"target:name", | ||
"-o", | ||
"--o", | ||
"-option", | ||
"--option", | ||
], | ||
) | ||
def test_invalid_alias_name(alias: str) -> None: | ||
with pytest.raises( | ||
CliAliasInvalidError, match=(f"Invalid alias in `\\[cli\\]\\.alias` option: {alias!r}\\.") | ||
): | ||
CliAlias.from_dict({alias: ""}) | ||
|
||
|
||
def test_banned_alias_names() -> None: | ||
cli_alias = CliAlias.from_dict({"fmt": "--cleverness format"}) | ||
with pytest.raises( | ||
CliAliasInvalidError, | ||
match=( | ||
r"Invalid alias in `\[cli\]\.alias` option: 'fmt'\. This is already a registered goal\." | ||
), | ||
): | ||
cli_alias.check_name_conflicts({"fmt": ScopeInfo("fmt", is_goal=True)}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.