feat: add superhelp and additional context via new FuncAlias (#5366)

### Goals

* Make callable aliases transparent.
* Catch errors in callable aliases and show the name of the source.
* Show additional attributes: thredable, capturable.
* Closes #5266

## Exception

### Before

```xsh
aliases['cd']
# <function xonsh.dirstack.cd>

aliases['trace']
# <function xonsh.aliases.trace>

aliases['null'] = lambda: 1/0
null
# ZeroDivisionError: division by zero

@aliases.register('catch')
@aliases.register('me')
@aliases.register('if')
@aliases.register('you')
@aliases.register('can')
def _exc(args, stdin, stdout):
    for line in stdin.readlines():
        print(line.strip() + '!', file=stdout, flush=True)
    return 1/0 if 'i' in $__ALIAS_NAME else 0

echo hey | catch | me | if | you | can
# ZeroDivisionError: division by zero      <--- ???
# hey!!!!!
```

### After

```xsh 
aliases['cd']
# FuncAlias({'name': 'cd', 'func': 'cd'})

aliases['trace']
# FuncAlias({'name': 'trace', 'func': 'trace', '__xonsh_threadable__': False})

$XONSH_SHOW_TRACEBACK=False
$RAISE_SUBPROC_ERROR = False
aliases['null'] = lambda: 1/0
null
#Exception in thread {'cls': 'ProcProxyThread', 'name': 'Thread-15', 'func': FuncAlias({'name': 'null', 'func': '<lambda>'}), 'alias': 'null', 'pid': None}
#ZeroDivisionError: division by zero



@aliases.register('catch')
@aliases.register('me')
@aliases.register('if')
@aliases.register('you')
@aliases.register('can')
def _exc(args, stdin, stdout):
    for line in stdin.readlines():
        print(line.strip() + '!', file=stdout, flush=True)
    return 1/0 if 'i' in $__ALIAS_NAME else 0
echo hey | catch | me | if | you | can
# Exception in thread {'cls': 'ProcProxyThread', 'name': 'Thread-8', 'func': FuncAlias({'name': 'if', 'func': '_exc'}), 'alias': 'if', 'pid': None}
# ZeroDivisionError: division by zero
# hey!!!!!
```

## Superhelp

### Before
```xsh
@aliases.register("hello")
def _alias_hello():
    """Show world."""
    print('world')

hello?
# No manual entry for hello
```

### After
```xsh
@aliases.register("hello")
def _alias_hello():
    """Show world."""
    print('world')

hello?
# FuncAlias({'name': 'hello', 'func': '_alias_hello'}):
# Show world.
```



## For community
⬇️ **Please click the 👍 reaction instead of leaving a `+1` or 👍
comment**

---------

Co-authored-by: a <1@1.1>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Andy Kipp 2024-05-13 15:11:58 +02:00 committed by GitHub
parent 55b341d477
commit bb394a8e84
Failed to generate hash of commit
8 changed files with 257 additions and 81 deletions

24
news/funcalias.rst Normal file
View file

@ -0,0 +1,24 @@
**Added:**
* Added FuncAlias to process callable aliases.
* Added alias name printing in case of exception in alias.
**Changed:**
* <news item>
**Deprecated:**
* <news item>
**Removed:**
* <news item>
**Fixed:**
* Fixed showing alias description using superhelp e.g. ``which?``.
**Security:**
* <news item>

View file

@ -9,7 +9,7 @@ import pytest
from xonsh.aliases import Aliases, ExecAlias from xonsh.aliases import Aliases, ExecAlias
def cd(args, stdin=None, **kwargs): def cd(args, stdin=None):
return args return args
@ -30,10 +30,11 @@ def test_imports(xession):
"o": ["omg", "lala"], "o": ["omg", "lala"],
"ls": ["ls", "- -"], "ls": ["ls", "- -"],
"color_ls": ["ls", "--color=true"], "color_ls": ["ls", "--color=true"],
"cd": cd, "cd": "FuncAlias",
"indirect_cd": ["cd", ".."], "indirect_cd": ["cd", ".."],
} }
raw = ales._raw raw = ales._raw
raw["cd"] = type(ales["cd"]).__name__
assert raw == expected assert raw == expected

View file

@ -1000,6 +1000,177 @@ def test_run_fail_not_on_path():
assert out != "Hello world" assert out != "Hello world"
ALIASES_THREADABLE_PRINT_CASES = [
(
"""
$RAISE_SUBPROC_ERROR = False
$XONSH_SHOW_TRACEBACK = False
aliases['f'] = lambda: 1/0
echo f1f1f1 ; f ; echo f2f2f2
""",
"^f1f1f1\nException in thread.*FuncAlias.*\nZeroDivisionError.*\nf2f2f2\n$",
),
(
"""
$RAISE_SUBPROC_ERROR = True
$XONSH_SHOW_TRACEBACK = False
aliases['f'] = lambda: 1/0
echo f1f1f1 ; f ; echo f2f2f2
""",
"f1f1f1\nException in thread.*\nZeroDivisionError: .*\nsubprocess.CalledProcessError.*\n$",
),
(
"""
$RAISE_SUBPROC_ERROR = True
$XONSH_SHOW_TRACEBACK = True
aliases['f'] = lambda: 1/0
echo f1f1f1 ; f ; echo f2f2f2
""",
"f1f1f1\nException in thread.*\nTraceback.*\nZeroDivisionError: .*\nsubprocess.CalledProcessError.*\n$",
),
(
"""
$RAISE_SUBPROC_ERROR = False
$XONSH_SHOW_TRACEBACK = True
aliases['f'] = lambda: 1/0
echo f1f1f1 ; f ; echo f2f2f2
""",
"f1f1f1\nException in thread.*FuncAlias.*\nTraceback.*\nZeroDivisionError.*\nf2f2f2\n$",
),
(
"""
$RAISE_SUBPROC_ERROR = False
$XONSH_SHOW_TRACEBACK = False
aliases['f'] = lambda: (None, "I failed", 2)
echo f1f1f1 ; f ; echo f2f2f2
""",
"^f1f1f1\nI failed\nf2f2f2\n$",
),
(
"""
$RAISE_SUBPROC_ERROR = True
$XONSH_SHOW_TRACEBACK = False
aliases['f'] = lambda: (None, "I failed", 2)
echo f1f1f1 ; f ; echo f2f2f2
""",
"f1f1f1\nI failed\nsubprocess.CalledProcessError.*\n$",
),
(
"""
$RAISE_SUBPROC_ERROR = True
$XONSH_SHOW_TRACEBACK = True
aliases['f'] = lambda: (None, "I failed", 2)
echo f1f1f1 ; f ; echo f2f2f2
""",
"f1f1f1\nI failed.*\nTraceback.*\nsubprocess.CalledProcessError.*\n$",
),
(
"""
$RAISE_SUBPROC_ERROR = False
$XONSH_SHOW_TRACEBACK = True
aliases['f'] = lambda: (None, "I failed", 2)
echo f1f1f1 ; f ; echo f2f2f2
""",
"f1f1f1\nI failed\nf2f2f2\n$",
),
]
ALIASES_UNTHREADABLE_PRINT_CASES = [
(
"""
$RAISE_SUBPROC_ERROR = False
$XONSH_SHOW_TRACEBACK = False
aliases['f'] = lambda: 1/0
aliases['f'].__xonsh_threadable__ = False
echo f1f1f1 ; f ; echo f2f2f2
""",
"^f1f1f1\nException in.*FuncAlias.*\nZeroDivisionError.*\nf2f2f2\n$",
),
(
"""
$RAISE_SUBPROC_ERROR = True
$XONSH_SHOW_TRACEBACK = False
aliases['f'] = lambda: 1/0
aliases['f'].__xonsh_threadable__ = False
echo f1f1f1 ; f ; echo f2f2f2
""",
"f1f1f1\nException in.*\nZeroDivisionError: .*\nsubprocess.CalledProcessError.*\n$",
),
(
"""
$RAISE_SUBPROC_ERROR = True
$XONSH_SHOW_TRACEBACK = True
aliases['f'] = lambda: 1/0
aliases['f'].__xonsh_threadable__ = False
echo f1f1f1 ; f ; echo f2f2f2
""",
"f1f1f1\nException in.*\nTraceback.*\nZeroDivisionError: .*\nsubprocess.CalledProcessError.*\n$",
),
(
"""
$RAISE_SUBPROC_ERROR = False
$XONSH_SHOW_TRACEBACK = True
aliases['f'] = lambda: 1/0
aliases['f'].__xonsh_threadable__ = False
echo f1f1f1 ; f ; echo f2f2f2
""",
"f1f1f1\nException in.*FuncAlias.*\nTraceback.*\nZeroDivisionError.*\nf2f2f2\n$",
),
(
"""
$RAISE_SUBPROC_ERROR = False
$XONSH_SHOW_TRACEBACK = False
aliases['f'] = lambda: (None, "I failed", 2)
aliases['f'].__xonsh_threadable__ = False
echo f1f1f1 ; f ; echo f2f2f2
""",
"^f1f1f1\nI failed\nf2f2f2\n$",
),
(
"""
$RAISE_SUBPROC_ERROR = True
$XONSH_SHOW_TRACEBACK = False
aliases['f'] = lambda: (None, "I failed", 2)
aliases['f'].__xonsh_threadable__ = False
echo f1f1f1 ; f ; echo f2f2f2
""",
"f1f1f1\nI failed\nsubprocess.CalledProcessError.*\n$",
),
(
"""
$RAISE_SUBPROC_ERROR = True
$XONSH_SHOW_TRACEBACK = True
aliases['f'] = lambda: (None, "I failed", 2)
aliases['f'].__xonsh_threadable__ = False
echo f1f1f1 ; f ; echo f2f2f2
""",
"f1f1f1\nI failed.*\nTraceback.*\nsubprocess.CalledProcessError.*\n$",
),
(
"""
$RAISE_SUBPROC_ERROR = False
$XONSH_SHOW_TRACEBACK = True
aliases['f'] = lambda: (None, "I failed", 2)
aliases['f'].__xonsh_threadable__ = False
echo f1f1f1 ; f ; echo f2f2f2
""",
"f1f1f1\nI failed\nf2f2f2\n$",
),
]
@skip_if_on_windows
@pytest.mark.parametrize(
"case", ALIASES_THREADABLE_PRINT_CASES + ALIASES_UNTHREADABLE_PRINT_CASES
)
def test_aliases_print(case):
cmd, match = case
out, err, ret = run_xonsh(cmd=cmd, single_command=False)
assert re.match(
match, out, re.MULTILINE | re.DOTALL
), f"\nFailed:\n```\n{cmd.strip()}\n```,\nresult: {out!r}\nexpected: {match!r}."
@skip_if_on_windows @skip_if_on_windows
@pytest.mark.parametrize("interactive", [True, False]) @pytest.mark.parametrize("interactive", [True, False])
def test_raise_subproc_error_with_show_traceback(monkeypatch, interactive): def test_raise_subproc_error_with_show_traceback(monkeypatch, interactive):

View file

@ -2122,7 +2122,7 @@ def test_print_exception_error(xession, capsys):
match, match,
cap.err, cap.err,
re.MULTILINE | re.DOTALL, re.MULTILINE | re.DOTALL,
), f"Assert: {cap.err!r} not matched with {match!r}" ), f"\nAssert: {cap.err!r},\nexpected: {match!r}"
with xession.env.swap(XONSH_SHOW_TRACEBACK=True): with xession.env.swap(XONSH_SHOW_TRACEBACK=True):
try: try:
@ -2130,9 +2130,9 @@ def test_print_exception_error(xession, capsys):
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
print_exception(msg="MSG") print_exception(msg="MSG")
cap = capsys.readouterr() cap = capsys.readouterr()
match = ".*Traceback.*subprocess.CalledProcessError: Command .* returned non-zero exit status .*\nMSG\n" match = ".*Traceback.*subprocess.CalledProcessError: Command .* returned non-zero exit status .*MSG\n"
assert re.match( assert re.match(
match, match,
cap.err, cap.err,
re.MULTILINE | re.DOTALL, re.MULTILINE | re.DOTALL,
), f"Assert: {cap.err!r} not matched with {match!r}" ), f"\nAssert: {cap.err!r},\nexpected {match!r}"

View file

@ -54,6 +54,37 @@ def EXEC_ALIAS_RE():
return re.compile(r"@\(|\$\(|!\(|\$\[|!\[|\&\&|\|\||\s+and\s+|\s+or\s+|[>|<]") return re.compile(r"@\(|\$\(|!\(|\$\[|!\[|\&\&|\|\||\s+and\s+|\s+or\s+|[>|<]")
class FuncAlias:
"""Provides a callable alias for xonsh commands."""
attributes_show = ["__xonsh_threadable__", "__xonsh_capturable__"]
attributes_inherit = attributes_show + ["__doc__"]
def __init__(self, name, func):
self.__name__ = self.name = name
self.func = func
for attr in self.attributes_inherit:
if (val := getattr(func, attr, None)) is not None:
self.__setattr__(attr, val)
def __repr__(self):
r = {"name": self.name, "func": self.func.__name__}
r |= {
attr: val
for attr in self.attributes_show
if (val := getattr(self, attr, None)) is not None
}
return f"FuncAlias({repr(r)})"
def __call__(
self, args=None, stdin=None, stdout=None, stderr=None, spec=None, stack=None
):
func_args = [args, stdin, stdout, stderr, spec, stack][
: len(inspect.signature(self.func).parameters)
]
return self.func(*func_args)
class Aliases(cabc.MutableMapping): class Aliases(cabc.MutableMapping):
"""Represents a location to hold and look up aliases.""" """Represents a location to hold and look up aliases."""
@ -182,6 +213,8 @@ class Aliases(cabc.MutableMapping):
else: else:
# need to exec alias # need to exec alias
self._raw[key] = ExecAlias(val, filename=f) self._raw[key] = ExecAlias(val, filename=f)
elif isinstance(val, types.FunctionType):
self._raw[key] = FuncAlias(key, val)
else: else:
self._raw[key] = val self._raw[key] = val
@ -225,7 +258,7 @@ class Aliases(cabc.MutableMapping):
class ExecAlias: class ExecAlias:
"""Provides a callable alias for xonsh source code.""" """Provides an exec alias for xonsh source code."""
def __init__(self, src, filename="<exec-alias>"): def __init__(self, src, filename="<exec-alias>"):
""" """

View file

@ -9,7 +9,6 @@ licensed to the Python Software foundation under a Contributor Agreement.
import collections.abc as cabc import collections.abc as cabc
import functools import functools
import inspect
import io import io
import os import os
import signal import signal
@ -274,7 +273,7 @@ def parse_proxy_return(r, stdout, stderr):
stdout.write(str(r[0])) stdout.write(str(r[0]))
stdout.flush() stdout.flush()
if rlen > 1 and r[1] is not None: if rlen > 1 and r[1] is not None:
stderr.write(str(r[1])) stderr.write(xt.endswith_newline(str(r[1])))
stderr.flush() stderr.flush()
if rlen > 2 and isinstance(r[2], int): if rlen > 2 and isinstance(r[2], int):
cmd_result = r[2] cmd_result = r[2]
@ -285,69 +284,6 @@ def parse_proxy_return(r, stdout, stderr):
return cmd_result return cmd_result
def proxy_zero(f, args, stdin, stdout, stderr, spec, stack):
"""Calls a proxy function which takes no parameters."""
return f()
def proxy_one(f, args, stdin, stdout, stderr, spec, stack):
"""Calls a proxy function which takes one parameter: args"""
return f(args)
def proxy_two(f, args, stdin, stdout, stderr, spec, stack):
"""Calls a proxy function which takes two parameter: args and stdin."""
return f(args, stdin)
def proxy_three(f, args, stdin, stdout, stderr, spec, stack):
"""Calls a proxy function which takes three parameter: args, stdin, stdout."""
return f(args, stdin, stdout)
def proxy_four(f, args, stdin, stdout, stderr, spec, stack):
"""Calls a proxy function which takes four parameter: args, stdin, stdout,
and stderr.
"""
return f(args, stdin, stdout, stderr)
def proxy_five(f, args, stdin, stdout, stderr, spec, stack):
"""Calls a proxy function which takes four parameter: args, stdin, stdout,
stderr, and spec.
"""
return f(args, stdin, stdout, stderr, spec)
PROXIES = (proxy_zero, proxy_one, proxy_two, proxy_three, proxy_four, proxy_five)
def partial_proxy(f):
"""Dispatches the appropriate proxy function based on the number of args."""
numargs = 0
for name, param in inspect.signature(f).parameters.items():
# handle *args/**kwargs signature
if param.kind in {param.VAR_KEYWORD, param.VAR_POSITIONAL}:
numargs = 6
break
if (
param.kind == param.POSITIONAL_ONLY
or param.kind == param.POSITIONAL_OR_KEYWORD
):
numargs += 1
elif name in xt.ALIAS_KWARG_NAMES and param.kind == param.KEYWORD_ONLY:
numargs += 1
if numargs < 6:
return functools.partial(PROXIES[numargs], f)
elif numargs == 6:
# don't need to partial.
return f
else:
e = "Expected proxy with 6 or fewer arguments for {}, not {}"
raise xt.XonshError(e.format(", ".join(xt.ALIAS_KWARG_NAMES), numargs))
def get_proc_proxy_name(cls): def get_proc_proxy_name(cls):
func_name = cls.f func_name = cls.f
if type(cls.f) is functools.partial: if type(cls.f) is functools.partial:
@ -409,8 +345,7 @@ class ProcProxyThread(threading.Thread):
env : Mapping, optional env : Mapping, optional
Environment mapping. Environment mapping.
""" """
self.orig_f = f self.f = f
self.f = partial_proxy(f)
self.args = args self.args = args
self.pid = None self.pid = None
self.returncode = None self.returncode = None
@ -799,8 +734,7 @@ class ProcProxy:
close_fds=False, close_fds=False,
env=None, env=None,
): ):
self.orig_f = f self.f = f
self.f = partial_proxy(f)
self.args = args self.args = args
self.pid = os.getpid() self.pid = os.getpid()
self.returncode = None self.returncode = None

View file

@ -488,10 +488,18 @@ class SubprocSpec:
except FileNotFoundError as ex: except FileNotFoundError as ex:
cmd0 = self.cmd[0] cmd0 = self.cmd[0]
if len(self.cmd) == 1 and cmd0.endswith("?"): if len(self.cmd) == 1 and cmd0.endswith("?"):
with contextlib.suppress(OSError): cmdq = cmd0.rstrip("?")
return self.cls( if cmdq in XSH.aliases:
["man", cmd0.rstrip("?")], bufsize=bufsize, **kwargs alias = XSH.aliases[cmdq]
descr = (
repr(alias) + (":\n" + doc)
if (doc := getattr(alias, "__doc__", ""))
else ""
) )
return self.cls(["echo", descr], bufsize=bufsize, **kwargs)
else:
with contextlib.suppress(OSError):
return self.cls(["man", cmdq], bufsize=bufsize, **kwargs)
e = f"xonsh: subprocess mode: command not found: {repr(cmd0)}" e = f"xonsh: subprocess mode: command not found: {repr(cmd0)}"
env = XSH.env env = XSH.env
sug = xt.suggest_commands(cmd0, env) sug = xt.suggest_commands(cmd0, env)
@ -701,7 +709,7 @@ class SubprocSpec:
if not callable(self.alias): if not callable(self.alias):
return return
# check that we actual need the stack # check that we actual need the stack
sig = inspect.signature(self.alias) sig = inspect.signature(getattr(self.alias, "func", self.alias))
if len(sig.parameters) <= 5 and "stack" not in sig.parameters: if len(sig.parameters) <= 5 and "stack" not in sig.parameters:
return return
# compute the stack, and filter out these build methods # compute the stack, and filter out these build methods

View file

@ -1116,7 +1116,7 @@ def display_colored_error_message(exc_info, strip_xonsh_error_types=True, limit=
content = traceback.format_exception(*exc_info, limit=limit) content = traceback.format_exception(*exc_info, limit=limit)
if no_trace_and_raise_subproc_error and "Error:" in content[-1]: if no_trace_and_raise_subproc_error and "Error:" in content[-1]:
content = [content[-1].rstrip()] content = [content[-1]]
traceback_str = "".join([v for v in content]) traceback_str = "".join([v for v in content])
@ -1133,7 +1133,7 @@ def display_colored_error_message(exc_info, strip_xonsh_error_types=True, limit=
lexer = pygments.lexers.python.PythonTracebackLexer() lexer = pygments.lexers.python.PythonTracebackLexer()
tokens = list(pygments.lex(traceback_str, lexer=lexer)) tokens = list(pygments.lex(traceback_str, lexer=lexer))
# this goes to stdout, but since we are interactive it doesn't matter # this goes to stdout, but since we are interactive it doesn't matter
print_color(tokens, end="\n", file=sys.stderr) print_color(tokens, end="", file=sys.stderr)
return return
@ -2858,3 +2858,8 @@ def unquote(s: str, chars="'\""):
if len(s) >= 2 and s[0] == s[-1] and s[0] in chars: if len(s) >= 2 and s[0] == s[-1] and s[0] in chars:
return s[1:-1] return s[1:-1]
return s return s
def endswith_newline(s: str):
"""Force one new line character end to string."""
return s.rstrip("\n") + "\n"