Skip to content

Commit

Permalink
Add [cli.alias] config section for command line alias support. (pan…
Browse files Browse the repository at this point in the history
…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
kaos authored and Eric-Arellano committed Oct 14, 2021
1 parent 50cc728 commit 5239864
Show file tree
Hide file tree
Showing 8 changed files with 400 additions and 36 deletions.
7 changes: 7 additions & 0 deletions pants.toml
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,17 @@ remote_store_address = "grpcs://cache.toolchain.com:443"
remote_instance_name = "main"
remote_auth_plugin = "toolchain.pants.auth.plugin:toolchain_auth_plugin"


[anonymous-telemetry]
enabled = true
repo_id = "7775F8D5-FC58-4DBC-9302-D00AE4A1505F"


[cli.alias]
all-changed = "--changed-since=HEAD --changed-dependees=transitive"
pyupgrade = "--backend-packages=pants.backend.python.lint.pyupgrade fmt"


[source]
root_patterns = [
"src/*",
Expand Down
3 changes: 2 additions & 1 deletion src/python/pants/build_graph/build_configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from pants.engine.rules import Rule, RuleIndex
from pants.engine.target import Target
from pants.engine.unions import UnionRule
from pants.option.alias import CliOptions
from pants.option.global_options import GlobalOptions
from pants.option.scope import normalize_scope
from pants.option.subsystem import Subsystem
Expand All @@ -31,7 +32,7 @@


# Subsystems used outside of any rule.
_GLOBAL_SUBSYSTEMS: Set[Type[Subsystem]] = {GlobalOptions, Changed}
_GLOBAL_SUBSYSTEMS: Set[Type[Subsystem]] = {GlobalOptions, Changed, CliOptions}


@dataclass(frozen=True)
Expand Down
129 changes: 129 additions & 0 deletions src/python/pants/option/alias.py
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)
151 changes: 151 additions & 0 deletions src/python/pants/option/alias_test.py
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)})
31 changes: 29 additions & 2 deletions src/python/pants/option/global_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Any, cast
from typing import Any, Type, cast

from pants.base.build_environment import (
get_buildroot,
Expand All @@ -36,7 +36,8 @@
from pants.util.dirutil import fast_relpath_optional
from pants.util.docutil import doc_url
from pants.util.logging import LogLevel
from pants.util.ordered_set import OrderedSet
from pants.util.memo import memoized_classmethod
from pants.util.ordered_set import FrozenOrderedSet, OrderedSet
from pants.util.osutil import CPU_COUNT
from pants.version import VERSION

Expand Down Expand Up @@ -1599,3 +1600,29 @@ def compute_pantsd_invalidation_globs(
)

return tuple(invalidation_globs)

@memoized_classmethod
def get_options_flags(cls) -> GlobalOptionsFlags:
return GlobalOptionsFlags.create(cast("Type[GlobalOptions]", cls))


@dataclass(frozen=True)
class GlobalOptionsFlags:
flags: FrozenOrderedSet[str]
short_flags: FrozenOrderedSet[str]

@classmethod
def create(cls, GlobalOptionsType: Type[GlobalOptions]) -> GlobalOptionsFlags:
flags = set()
short_flags = set()

def capture_the_flags(*args: str, **kwargs) -> None:
for arg in args:
flags.add(arg)
if len(arg) == 2:
short_flags.add(arg)
elif kwargs.get("type") == bool:
flags.add(f"--no-{arg[2:]}")

GlobalOptionsType.register_bootstrap_options(capture_the_flags)
return cls(FrozenOrderedSet(flags), FrozenOrderedSet(short_flags))
Loading

0 comments on commit 5239864

Please sign in to comment.