Repository: xonsh/xonsh

Issue:
Python expression evaluation using @() not allowed as subexpression 
I am trying to do something like this:

```
def upload_keys(user, server):
    scp /home/@(user)/.ssh/* @(user)@@(server):.ssh/.
```
Unfortunately it seems that the @() operator needs to be surrounded with spaces. 
```
def upload_keys(user, server):
    scp /home/ @(user) /.ssh/* @(user) @ @(server) :.ssh/.
```
This however doesn't work, because now the scp commands sees separate arguments.

A work around could be this
```
def upload_keys(user, server):
    $USER=user
    $SERVER=server
    scp /home/$USER/.ssh/* $USER@$SERVER:.ssh/.
```
But this pollutes the environment and results in a lot of copying.

Any clean solution to work with Python expressions as part of shell commands?




Discussion:
You can put the variables in a single `@()` ...

Something like this:

```
snail@sea ~
$ user = 'snail'
. server ='sea'
. echo @(f'{user}@{server}:.ssh/.')
.
snail@sea:.ssh/.
```
Thanks for you quick reply, 

Funny, I was just typing feedback trying this approach. Will continue using your suggestion.

No problem. Feel free to close if you think this answers your question. 
Actually, I think this is a feature that would be nice to have.  I think it would involve extending `expand_path()` to handle the `@(expr)` syntax.
Using @(expr) as part of a path/argurment would indeed be cleaner when compared with using the python string interpolation @(f'{user}@{server}:.ssh/.') approach.

I've looked at the expandvars function that expands $(x). It uses a regular expression to find the ${x} tags. Using a regex for finding @(expr) with arbitrary python expressions would be very hard. Maybe its possible to to find @( and then use the xonsh Python parser?



Resolving patch:
diff --git a/xonsh/built_ins.py b/xonsh/built_ins.py
--- a/xonsh/built_ins.py
+++ b/xonsh/built_ins.py
@@ -972,6 +972,19 @@ def list_of_strs_or_callables(x):
     return rtn
 
 
+def list_of_list_of_strs_outer_product(x):
+    """Takes an outer product of a list of strings"""
+    lolos = map(ensure_list_of_strs, x)
+    rtn = []
+    for los in itertools.product(*lolos):
+        s = "".join(los)
+        if "*" in s:
+            rtn.extend(builtins.__xonsh_glob__(s))
+        else:
+            rtn.append(builtins.__xonsh_expand_path__(s))
+    return rtn
+
+
 @lazyobject
 def MACRO_FLAG_KINDS():
     return {
@@ -1237,6 +1250,9 @@ def load_builtins(execer=None, ctx=None):
     builtins.__xonsh_all_jobs__ = {}
     builtins.__xonsh_ensure_list_of_strs__ = ensure_list_of_strs
     builtins.__xonsh_list_of_strs_or_callables__ = list_of_strs_or_callables
+    builtins.__xonsh_list_of_list_of_strs_outer_product__ = (
+        list_of_list_of_strs_outer_product
+    )
     builtins.__xonsh_completers__ = xonsh.completers.init.default_completers()
     builtins.__xonsh_call_macro__ = call_macro
     builtins.__xonsh_enter_macro__ = enter_macro
@@ -1316,6 +1332,7 @@ def unload_builtins():
         "__xonsh_all_jobs__",
         "__xonsh_ensure_list_of_strs__",
         "__xonsh_list_of_strs_or_callables__",
+        "__xonsh_list_of_list_of_strs_outer_product__",
         "__xonsh_history__",
     ]
     for name in names:
diff --git a/xonsh/parsers/base.py b/xonsh/parsers/base.py
--- a/xonsh/parsers/base.py
+++ b/xonsh/parsers/base.py
@@ -194,6 +194,20 @@ def lopen_loc(x):
     return lineno, col
 
 
+def hasglobstar(x):
+    """Returns True if a node has literal '*' for globbing."""
+    if isinstance(x, ast.Str):
+        return "*" in x.s
+    elif isinstance(x, list):
+        for e in x:
+            if hasglobstar(e):
+                return True
+        else:
+            return False
+    else:
+        return False
+
+
 class YaccLoader(Thread):
     """Thread to load (but not shave) the yacc parser."""
 
@@ -386,6 +400,44 @@ def __init__(
             "dedent",
             "newline",
             "lambda",
+            "ampersandequal",
+            "as",
+            "atdollar",
+            "atequal",
+            "break",
+            "continue",
+            "divequal",
+            "dollar_name",
+            "double_question",
+            "doubledivequal",
+            "elif",
+            "else",
+            "eq",
+            "equals",
+            "errortoken",
+            "finally",
+            "ge",
+            "in",
+            "is",
+            "le",
+            "lshiftequal",
+            "minusequal",
+            "modequal",
+            "ne",
+            "pass",
+            "period",
+            "pipeequal",
+            "plusequal",
+            "pow",
+            "powequal",
+            "question",
+            "rarrow",
+            "rshiftequal",
+            "semi",
+            "tilde",
+            "timesequal",
+            "while",
+            "xorequal",
         ]
         for rule in tok_rules:
             self._tok_rule(rule)
@@ -3008,7 +3060,9 @@ def p_subproc_atom_pyenv_lookup(self, p):
         p[0] = p0
 
     def p_subproc_atom_pyeval(self, p):
-        """subproc_atom : at_lparen_tok testlist_comp RPAREN"""
+        """subproc_atom : at_lparen_tok testlist_comp RPAREN
+           subproc_arg_part : at_lparen_tok testlist_comp RPAREN
+        """
         p1 = p[1]
         p0 = xonsh_call(
             "__xonsh_list_of_strs_or_callables__",
@@ -3065,15 +3119,25 @@ def p_subproc_atom_str(self, p):
     def p_subproc_atom_arg(self, p):
         """subproc_atom : subproc_arg"""
         p1 = p[1]
-        p0 = ast.Str(s=p[1], lineno=self.lineno, col_offset=self.col)
-        if "*" in p1:
+        if isinstance(p1, list):
+            # has an expanding function call, such as @(x)
+            p0 = xonsh_call(
+                "__xonsh_list_of_list_of_strs_outer_product__",
+                args=[ensure_has_elts(p1)],
+                lineno=p1[0].lineno,
+                col=p1[0].col_offset,
+            )
+            p0._cliarg_action = "extend"
+        elif hasglobstar(p1):
+            # globbed literal argument
             p0 = xonsh_call(
-                "__xonsh_glob__", args=[p0], lineno=self.lineno, col=self.col
+                "__xonsh_glob__", args=[p1], lineno=p1.lineno, col=p1.col_offset
             )
             p0._cliarg_action = "extend"
         else:
+            # literal str argument
             p0 = xonsh_call(
-                "__xonsh_expand_path__", args=[p0], lineno=self.lineno, col=self.col
+                "__xonsh_expand_path__", args=[p1], lineno=p1.lineno, col=p1.col_offset
             )
             p0._cliarg_action = "append"
         p[0] = p0
@@ -3085,7 +3149,22 @@ def p_subproc_arg_single(self, p):
     def p_subproc_arg_many(self, p):
         """subproc_arg : subproc_arg subproc_arg_part"""
         # This glues the string together after parsing
-        p[0] = p[1] + p[2]
+        p1 = p[1]
+        p2 = p[2]
+        if isinstance(p1, ast.Str) and isinstance(p2, ast.Str):
+            p0 = ast.Str(p1.s + p2.s, lineno=p1.lineno, col_offset=p1.col_offset)
+        elif isinstance(p1, list):
+            if isinstance(p2, list):
+                p1.extend(p2)
+            else:
+                p1.append(p2)
+            p0 = p1
+        elif isinstance(p2, list):
+            p2.insert(0, p1)
+            p0 = p2
+        else:
+            p0 = [p1, p2]
+        p[0] = p0
 
     def _attach_subproc_arg_part_rules(self):
         toks = set(self.tokens)
@@ -3118,14 +3197,15 @@ def _attach_subproc_arg_part_rules(self):
             "DOLLAR_LBRACKET",
             "ATDOLLAR_LPAREN",
         }
-        ts = "\n                 | ".join(sorted(toks))
+        ts = "\n                 | ".join(sorted([t.lower() + "_tok" for t in toks]))
         doc = "subproc_arg_part : " + ts + "\n"
         self.p_subproc_arg_part.__func__.__doc__ = doc
 
     def p_subproc_arg_part(self, p):
         # Many tokens cannot be part of this rule, such as $, ', ", ()
         # Use a string atom instead. See above attachment functions
-        p[0] = p[1]
+        p1 = p[1]
+        p[0] = ast.Str(s=p1.value, lineno=p1.lineno, col_offset=p1.lexpos)
 
     #
     # Helpers


Test patch:
diff --git a/tests/test_builtins.py b/tests/test_builtins.py
--- a/tests/test_builtins.py
+++ b/tests/test_builtins.py
@@ -17,6 +17,7 @@
     superhelper,
     ensure_list_of_strs,
     list_of_strs_or_callables,
+    list_of_list_of_strs_outer_product,
     regexsearch,
     globsearch,
     expand_path,
@@ -142,6 +143,19 @@ def test_list_of_strs_or_callables(exp, inp):
     assert exp == obs
 
 
+@pytest.mark.parametrize(
+    "inp, exp",
+    [
+        (["x", ["y", "z"]], ["xy", "xz"]),
+        (["x", ["y", "z"], ["a"]], ["xya", "xza"]),
+        ([["y", "z"], ["a", "b"]], ["ya", "yb", "za", "zb"]),
+    ],
+)
+def test_list_of_list_of_strs_outer_product(xonsh_builtins, inp, exp):
+    obs = list_of_list_of_strs_outer_product(inp)
+    assert exp == obs
+
+
 @pytest.mark.parametrize(
     "s",
     [
diff --git a/tests/test_parser.py b/tests/test_parser.py
--- a/tests/test_parser.py
+++ b/tests/test_parser.py
@@ -2060,6 +2060,10 @@ def test_nested_madness():
     )
 
 
+def test_atparens_intoken():
+    check_xonsh_ast({}, "![echo /x/@(y)/z]", False)
+
+
 def test_ls_dot_nesting():
     check_xonsh_ast({}, '$(ls @(None or "."))', False)