From 87032f6d304b8925f21d0263b6eb0bec2b7a0bfd Mon Sep 17 00:00:00 2001 From: Andy Kipp Date: Fri, 12 Jul 2024 01:35:20 +0200 Subject: [PATCH] Alias that returns modified command (#5473) * Command Alias * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * tests * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * tests * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * news * docs * tests * docs * wip * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * tests * tests * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * tests * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * tests * tests * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * tests * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * tests * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * tests * tests * clean * news * news * bumptests * bumptests * new api * tests * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * doooocs * comments * comments * comments * aliases.CUT_ARGS * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * aliases.CUT_ARGS * aliases.CUT_ARGS * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * aliases.CUT_ARGS * aliases.CUT_ARGS * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * aliases.CUT_ARGS * comments * bump test * remove CUT_ARGS * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * test * test * wip * revert * wip * wip * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * wip * docs * news * tests * tests * test * test * test * Update docs/tutorial.rst Co-authored-by: Jason R. Coombs * Update xonsh/aliases.py Co-authored-by: Jason R. Coombs * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix, thanks jaraco! * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * more comments * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * cleaning --------- Co-authored-by: a <1@1.1> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Jason R. Coombs --- docs/tutorial.rst | 40 +++++++- news/alias_return_cmd.rst | 23 +++++ tests/procs/test_specs.py | 108 ++++++++++++++++++++++ tests/test_aliases.py | 46 ++++++++-- xonsh/aliases.py | 172 +++++++++++++++++++++++++++++------ xonsh/completers/_aliases.py | 2 +- xonsh/procs/specs.py | 42 ++++++--- 7 files changed, 382 insertions(+), 51 deletions(-) create mode 100644 news/alias_return_cmd.rst diff --git a/docs/tutorial.rst b/docs/tutorial.rst index 30cf8a1ea..0a39737e9 100644 --- a/docs/tutorial.rst +++ b/docs/tutorial.rst @@ -1275,7 +1275,7 @@ functions. If you don't know what these do, you probably don't need them. Aliases -============================== +======= Another important xonsh built-in is the ``aliases`` mapping. This is like a dictionary that affects how subprocess commands are run. If you are familiar with the Bash ``alias`` built-in, this is similar. Alias command @@ -1305,6 +1305,44 @@ If you were to run ``gco feature-fabulous`` with the above aliases in effect, the command would reduce to ``['git', 'checkout', 'feature-fabulous']`` before being executed. +Alias to modify command +----------------------- + +The best way to modify command on the fly is to use alias that returns modified command. +One of the most interesting application is expanding an alias: + +.. code-block:: xonshcon + + >>> @aliases.register + ... @aliases.return_command + ... def _xsudo(args): + ... """Sudo with expanding aliases.""" + ... return ['sudo', '--', *aliases.eval_alias(args)] + ... + >>> aliases['install'] = "apt install cowsay" + >>> xsudo install + # Password: + # Install cowsay + +Or implement logic to run the right command: + +.. code-block:: xonshcon + + >>> @aliases.register + ... @aliases.return_command + ... def _vi(args): + ... """Universal vi editor.""" + ... if $(which vim 2>/dev/null): + ... return ['vim'] + args + ... else: + ... return ['vi'] + args + ... + >>> vi file + + +ExecAlias +--------- + If the string is representing a block of xonsh code, the alias will be registered as an ``ExecAlias``, which is a callable alias. This block of code will then be executed whenever the alias is run. The arguments are available in the list ``$args`` diff --git a/news/alias_return_cmd.rst b/news/alias_return_cmd.rst new file mode 100644 index 000000000..2167c7d26 --- /dev/null +++ b/news/alias_return_cmd.rst @@ -0,0 +1,23 @@ +**Added:** + +* Added ``@aliases.return_command`` decorator to eliminate the need to wrap the logic for modifying command into callable alias wrapper (#5473). + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/tests/procs/test_specs.py b/tests/procs/test_specs.py index 1cf74dbc5..b307fc070 100644 --- a/tests/procs/test_specs.py +++ b/tests/procs/test_specs.py @@ -183,6 +183,7 @@ def test_interrupted_process_returncode(xonsh_session, captured, interactive): @skip_if_on_windows +@pytest.mark.flaky(reruns=3, reruns_delay=1) def test_proc_raise_subproc_error(xonsh_session): xonsh_session.env["RAISE_SUBPROC_ERROR"] = False @@ -469,3 +470,110 @@ def test_partial_args_from_classmethod(xession): xession.aliases["alias_with_partial_args"] = Class.alias out = run_subproc([["alias_with_partial_args"]], captured="stdout") assert out == "ok" + + +def test_alias_return_command_alone(xession): + @xession.aliases.register("wakka") + @xession.aliases.return_command + def _wakka(args): + return ["echo"] + args + + cmds = [ + ["wakka"], + ] + spec = cmds_to_specs(cmds, captured="object")[-1] + assert spec.cmd == ["echo"] + assert spec.alias_name == "wakka" + + +def test_alias_return_command_alone_args(xession): + @xession.aliases.register("wakka") + @xession.aliases.return_command + def _wakka(args): + return ["echo", "e0", "e1"] + args + + cmds = [ + ["wakka", "0", "1"], + ] + spec = cmds_to_specs(cmds, captured="object")[-1] + assert spec.cmd == ["echo", "e0", "e1", "0", "1"] + assert spec.alias_name == "wakka" + + +def test_alias_return_command_chain(xession): + xession.aliases["foreground"] = "midground f0 f1" + + @xession.aliases.register("midground") + @xession.aliases.return_command + def _midground(args): + return ["ground", "m0", "m1"] + args + + xession.aliases["ground"] = "background g0 g1" + xession.aliases["background"] = "echo b0 b1" + + cmds = [ + ["foreground", "0", "1"], + ] + spec = cmds_to_specs(cmds, captured="object")[-1] + assert spec.cmd == [ + "echo", + "b0", + "b1", + "g0", + "g1", + "m0", + "m1", + "f0", + "f1", + "0", + "1", + ] + assert spec.alias_name == "foreground" + + +def test_alias_return_command_chain_spec_modifiers(xession): + xession.aliases["foreground"] = "midground f0 f1" + + xession.aliases["xunthread"] = SpecAttrModifierAlias( + {"threadable": False, "force_threadable": False} + ) + + @xession.aliases.register("midground") + @xession.aliases.return_command + def _midground(args): + return ["ground", "m0", "m1"] + + xession.aliases["ground"] = "background g0 g1" + xession.aliases["background"] = "xunthread echo b0 b1" + + cmds = [ + ["foreground", "0", "1"], + ] + spec = cmds_to_specs(cmds, captured="object")[-1] + assert spec.cmd == ["echo", "b0", "b1", "g0", "g1", "m0", "m1"] + assert spec.alias_name == "foreground" + assert spec.threadable is False + + +def test_alias_return_command_eval_inside(xession): + xession.aliases["xthread"] = SpecAttrModifierAlias( + {"threadable": True, "force_threadable": True} + ) + + @xession.aliases.register("xsudo") + @xession.aliases.return_command + def _midground(args, spec_modifiers=None): + return [ + "sudo", + *xession.aliases.eval_alias(args, spec_modifiers=spec_modifiers), + ] + + xession.aliases["cmd"] = "xthread echo 1" + + cmds = [ + ["xsudo", "cmd"], + ] + spec = cmds_to_specs(cmds, captured="object")[-1] + assert spec.cmd == ["sudo", "echo", "1"] + assert spec.alias_name == "xsudo" + assert spec.threadable is True diff --git a/tests/test_aliases.py b/tests/test_aliases.py index a2c0e45a3..b672a180c 100644 --- a/tests/test_aliases.py +++ b/tests/test_aliases.py @@ -6,7 +6,7 @@ import sys import pytest -from xonsh.aliases import Aliases, ExecAlias +from xonsh.aliases import Aliases, ExecAlias, run_alias_by_params def cd(args, stdin=None): @@ -53,10 +53,17 @@ def test_eval_recursive(xession): assert ales.get("color_ls") == ["ls", "- -", "--color=true"] +def test_eval_callable(xession): + ales = make_aliases() + resolved = ales.get(["cd", "tmp"]) + assert callable(resolved[0]) + assert isinstance(resolved[1], str) + + def test_eval_recursive_callable_partial(xonsh_execer, xession): ales = make_aliases() xession.env["HOME"] = os.path.expanduser("~") - assert ales.get("indirect_cd")(["arg2", "arg3"]) == ["..", "arg2", "arg3"] + assert ales.get(["indirect_cd", "arg2", "arg3"])[1:] == ["..", "arg2", "arg3"] def _return_to_sender_all(args, stdin, stdout, stderr, spec, stack): @@ -74,9 +81,11 @@ def _return_to_sender_all(args, stdin, stdout, stderr, spec, stack): def test_recursive_callable_partial_all(xession): ales = Aliases({"rtn": _return_to_sender_all, "rtn-recurse": ["rtn", "arg1"]}) - alias = ales.get("rtn-recurse") + alias = ales.get("rtn-recurse")[0] assert callable(alias) - args, obs = alias(["arg2"], stdin="a", stdout="b", stderr="c", spec="d", stack="e") + args, obs = alias( + ["arg1", "arg2"], stdin="a", stdout="b", stderr="c", spec="d", stack="e" + ) assert args == ["arg1", "arg2"] assert len(obs) == 5 exp = {"stdin": "a", "stdout": "b", "stderr": "c", "spec": "d", "stack": "e"} @@ -89,9 +98,9 @@ def _return_to_sender_handles(args, stdin, stdout, stderr): def test_recursive_callable_partial_handles(xession): ales = Aliases({"rtn": _return_to_sender_handles, "rtn-recurse": ["rtn", "arg1"]}) - alias = ales.get("rtn-recurse") + alias = ales.get("rtn-recurse")[0] assert callable(alias) - args, obs = alias(["arg2"], stdin="a", stdout="b", stderr="c") + args, obs = alias(["arg1", "arg2"], stdin="a", stdout="b", stderr="c") assert args == ["arg1", "arg2"] assert len(obs) == 3 exp = {"stdin": "a", "stdout": "b", "stderr": "c"} @@ -104,7 +113,7 @@ def _return_to_sender_none(): def test_recursive_callable_partial_none(xession): ales = Aliases({"rtn": _return_to_sender_none, "rtn-recurse": ["rtn"]}) - alias = ales.get("rtn-recurse") + alias = ales.get("rtn-recurse")[0] assert callable(alias) args, obs = alias() assert args == "wakka" @@ -214,3 +223,26 @@ def test_register_decorator(xession): def _private(): ... assert set(aliases) == {"debug", "name", "private"} + + +def test_run_alias_by_params(): + def alias_named_params(args, stdout): + return (args, stdout) + + def alias_named_params_rev(stdout, args): + return (args, stdout) + + def alias_list_params(a, i, o, e): + return (a, i, o, e) + + assert run_alias_by_params(alias_named_params, {"args": 1, "stdout": 2}) == (1, 2) + assert run_alias_by_params(alias_named_params_rev, {"args": 1, "stdout": 2}) == ( + 1, + 2, + ) + assert run_alias_by_params(alias_list_params, {"args": 1, "stderr": 4}) == ( + 1, + None, + None, + 4, + ) diff --git a/xonsh/aliases.py b/xonsh/aliases.py index bc0e4b070..14038bb06 100644 --- a/xonsh/aliases.py +++ b/xonsh/aliases.py @@ -1,15 +1,17 @@ """Aliases for the xonsh shell.""" import argparse -import collections.abc as cabc import functools import inspect +import operator import os import re import shutil import sys import types import typing as tp +from collections import abc as cabc +from typing import Literal import xonsh.completers._aliases as xca import xonsh.history.main as xhm @@ -42,6 +44,7 @@ from xonsh.tools import ( argvquote, escape_windows_cmd_string, print_color, + print_exception, strip_simple_quotes, swap_values, to_repr_pretty_, @@ -59,8 +62,9 @@ def EXEC_ALIAS_RE(): class FuncAlias: """Provides a callable alias for xonsh commands.""" - attributes_show = ["__xonsh_threadable__", "__xonsh_capturable__"] + attributes_show = ["__xonsh_threadable__", "__xonsh_capturable__", "return_what"] attributes_inherit = attributes_show + ["__doc__"] + return_what: Literal["command", "result"] = "result" def __init__(self, name, func=None): self.__name__ = self.name = name @@ -79,12 +83,27 @@ class FuncAlias: return f"FuncAlias({repr(r)})" def __call__( - self, args=None, stdin=None, stdout=None, stderr=None, spec=None, stack=None + self, + args=None, + stdin=None, + stdout=None, + stderr=None, + spec=None, + stack=None, + spec_modifiers=None, ): - func_args = [args, stdin, stdout, stderr, spec, stack][ - : len(inspect.signature(self.func).parameters) - ] - return self.func(*func_args) + return run_alias_by_params( + self.func, + { + "args": args, + "stdin": stdin, + "stdout": stdout, + "stderr": stderr, + "spec": spec, + "stack": stack, + "spec_modifiers": spec_modifiers, + }, + ) class Aliases(cabc.MutableMapping): @@ -132,28 +151,17 @@ class Aliases(cabc.MutableMapping): return wrapper - def get(self, key, default=None, spec_modifiers=None): - """Returns the (possibly modified) value. If the key is not present, - then `default` is returned. - If the value is callable, it is returned without modification. If it - is an iterable of strings it will be evaluated recursively to expand - other aliases, resulting in a new list or a "partially applied" - callable. - """ - spec_modifiers = spec_modifiers if spec_modifiers is not None else [] - val = self._raw.get(key) - if val is None: - return default - elif isinstance(val, cabc.Iterable) or callable(val): - return self.eval_alias( - val, seen_tokens={key}, spec_modifiers=spec_modifiers - ) - else: - msg = "alias of {!r} has an inappropriate type: {!r}" - raise TypeError(msg.format(key, val)) + def return_command(self, f): + """Decorator that switches alias from returning result to return in new command for execution.""" + f.return_what = "command" + return f def eval_alias( - self, value, seen_tokens=frozenset(), acc_args=(), spec_modifiers=None + self, + value, + seen_tokens=frozenset(), + acc_args=(), + spec_modifiers=None, ): """ "Evaluates" the alias ``value``, by recursively looking up the leftmost @@ -182,8 +190,18 @@ class Aliases(cabc.MutableMapping): break value = value[i:] + if callable(value) and getattr(value, "return_what", "result") == "command": + try: + value = value(acc_args, spec_modifiers=spec_modifiers) + acc_args = [] + except Exception as e: + print_exception(f"Exception inside alias {value}: {e}") + return None + if not len(value): + raise ValueError("return_command alias: zero arguments.") + if callable(value): - return partial_eval_alias(value, acc_args=acc_args) + return [value] + list(acc_args) else: expand_path = XSH.expand_path token, *rest = map(expand_path, value) @@ -205,6 +223,54 @@ class Aliases(cabc.MutableMapping): spec_modifiers=spec_modifiers, ) + def get( + self, + key, + default=None, + spec_modifiers=None, + ): + """ + Returns list that represent command with resolved aliases. + The ``key`` can be string with alias name or list for a command. + In the first position will be the resolved command name or callable alias. + If the key is not present, then `default` is returned. + + ``spec_modifiers`` is the list of SpecModifier objects that found during + resolving aliases (#5443). + + Note! The return value is always list because during resolving + we can find return_command alias that can completely replace + command and add new arguments. + """ + spec_modifiers = spec_modifiers if spec_modifiers is not None else [] + args = [] + if isinstance(key, list): + args = key[1:] + key = key[0] + val = self._raw.get(key) + if callable(val) and getattr(val, "return_what", "result") == "command": + try: + val = val(args, spec_modifiers=spec_modifiers) + args = [] + except Exception as e: + print_exception(f"Exception inside alias {key!r}: {e}") + return None + if not len(val): + raise ValueError("return_command alias: zero arguments.") + + if val is None: + return default + elif isinstance(val, cabc.Iterable) or callable(val): + return self.eval_alias( + val, + seen_tokens={key}, + spec_modifiers=spec_modifiers, + acc_args=args, + ) + else: + msg = "alias of {!r} has an inappropriate type: {!r}" + raise TypeError(msg.format(key, val)) + def expand_alias(self, line: str, cursor_index: int) -> str: """Expands any aliases present in line if alias does not point to a builtin function and if alias is only a single command. @@ -408,6 +474,21 @@ class PartialEvalAlias6(PartialEvalAliasBase): return self.f(args, stdin, stdout, stderr, spec, stack) +class PartialEvalAlias7(PartialEvalAliasBase): + def __call__( + self, + args, + stdin=None, + stdout=None, + stderr=None, + spec=None, + stack=None, + spec_modifiers=None, + ): + args = list(self.acc_args) + args + return self.f(args, stdin, stdout, stderr, spec, stack, spec_modifiers) + + PARTIAL_EVAL_ALIASES = ( PartialEvalAlias0, PartialEvalAlias1, @@ -416,6 +497,7 @@ PARTIAL_EVAL_ALIASES = ( PartialEvalAlias4, PartialEvalAlias5, PartialEvalAlias6, + PartialEvalAlias7, ) @@ -436,13 +518,43 @@ def partial_eval_alias(f, acc_args=()): numargs += 1 elif name in ALIAS_KWARG_NAMES and param.kind == param.KEYWORD_ONLY: numargs += 1 - if numargs < 7: + if numargs < 8: return PARTIAL_EVAL_ALIASES[numargs](f, acc_args=acc_args) else: - e = "Expected proxy with 6 or fewer arguments for {}, not {}" + e = "Expected proxy with 7 or fewer arguments for {}, not {}" raise XonshError(e.format(", ".join(ALIAS_KWARG_NAMES), numargs)) +def run_alias_by_params(func: tp.Callable, params: dict[str, tp.Any]): + """ + Run alias function based on signature and params. + If function param names are in alias signature fill them. + If function params have unknown names fill using alias signature order. + """ + alias_params = { + "args": None, + "stdin": None, + "stdout": None, + "stderr": None, + "spec": None, + "stack": None, + "spec_modifiers": None, + } + alias_params |= params + sign = inspect.signature(func) + func_params = sign.parameters.items() + kwargs = { + name: alias_params[name] for name, p in func_params if name in alias_params + } + + if len(kwargs) != len(func_params): + # There is unknown param. Switch to positional mode. + kwargs = dict( + zip(map(operator.itemgetter(0), func_params), alias_params.values()) + ) + return func(**kwargs) + + # # Actual aliases below # diff --git a/xonsh/completers/_aliases.py b/xonsh/completers/_aliases.py index 6aeb069dc..2723710e6 100644 --- a/xonsh/completers/_aliases.py +++ b/xonsh/completers/_aliases.py @@ -158,7 +158,7 @@ def complete_aliases(command: CommandContext): if cmd not in XSH.aliases: # only complete aliases return - alias = XSH.aliases.get(cmd) # type: ignore + alias = XSH.aliases.get(cmd)[0] # type: ignore completer = getattr(alias, "xonsh_complete", None) if not completer: diff --git a/xonsh/procs/specs.py b/xonsh/procs/specs.py index 6df5c96d6..4f504682f 100644 --- a/xonsh/procs/specs.py +++ b/xonsh/procs/specs.py @@ -705,29 +705,42 @@ class SubprocSpec: self.cmd = new_cmd def resolve_alias(self): - """Sets alias in command, if applicable.""" + """Resolving alias and setting up command.""" cmd0 = self.cmd[0] - spec_modifiers = [] if cmd0 in self.alias_stack: # Disabling the alias resolving to prevent infinite loop in call stack - # and futher using binary_loc to resolve the alias name. + # and further using binary_loc to resolve the alias name. self.alias = None return if callable(cmd0): - alias = cmd0 + self.alias = cmd0 else: + found_spec_modifiers = [] if isinstance(XSH.aliases, dict): # Windows tests alias = XSH.aliases.get(cmd0, None) + if alias is not None: + alias = alias + self.cmd[1:] else: - alias = XSH.aliases.get(cmd0, None, spec_modifiers=spec_modifiers) + alias = XSH.aliases.get( + self.cmd, + None, + spec_modifiers=found_spec_modifiers, + ) if alias is not None: self.alias_name = cmd0 - self.alias = alias - if spec_modifiers: - for mod in spec_modifiers: - self.add_spec_modifier(mod) + if callable(alias[0]): + # E.g. `alias == [FuncAlias({'name': 'cd'}), '/tmp']` + self.alias = alias[0] + self.cmd = [cmd0] + alias[1:] + else: + # E.g. `alias == ['ls', '-la']` + self.alias = alias + + if found_spec_modifiers: + for mod in found_spec_modifiers: + self.add_spec_modifier(mod) def resolve_binary_loc(self): """Sets the binary location""" @@ -765,8 +778,7 @@ class SubprocSpec: self.cmd.pop(0) return else: - self.cmd = alias + self.cmd[1:] - # resolve any redirects the aliases may have applied + self.cmd = alias self.resolve_redirects() if self.binary_loc is None: return @@ -971,7 +983,13 @@ def _trace_specs(trace_mode, specs, cmds, captured): } p |= { a: getattr(s, a, None) - for a in ["alias_name", "binary_loc", "threadable", "background"] + for a in [ + "alias_name", + "alias", + "binary_loc", + "threadable", + "background", + ] } if trace_mode == 3: p |= {