Repository: xonsh/xonsh

Issue:
Main branch regress: redirect to python substitution
```xsh
xonsh --no-rc
echo 1 > @('/tmp/tmp')
# xonsh: None: unable to open file # it's from specs.py:resolve_redirects because parsing is wrong
```

## For community
⬇️  **Please click the 👍 reaction instead of leaving a `+1` or 👍  comment**



Discussion:
Hi @yaxollum!
I found that the commit that did breaking changes:
* [Fix parsing of redirect tokens (#5322)](https://github.com/xonsh/xonsh/commit/08ac0d97590567728d1b0fb817c70eeb617766ca) (08ac0d97590567728d1b0fb817c70eeb617766ca, #5322)

Please take a look and add more tests (I'm using [this](https://github.com/xonsh/xonsh/pull/5470) for test). Thanks!

Also I see that command `echo 1 > file` becomes `['echo', '1', ('>',), 'file']` in `_flatten_cmd_redirects(cmd)` maybe this is the cause of None error.

I'm using this to find regress:
```xsh
# Checkout every commit and test command

$RAISE_SUBPROC_ERROR=True
mkdir -p /tmp/xonsh_dev
cd /tmp/xonsh_dev
git clone https://github.com/xonsh/xonsh
cd xonsh

xonsh_bin = ["python", "-m", "xonsh", "--no-rc"]
commits = [(c.split(' ', 1)[0], c.split(' ', 1)[1]) for c in $(git log '43c9aaf..' --reverse --oneline)]
for hash, descr in commits:
    printx(f"{{YELLOW}}{hash} {descr[:80]!r}{{RESET}}")
    _ = $(git checkout -f @(hash))
    @(xonsh_bin) -V
    # Check regress
    @(xonsh_bin) -c "echo 123 > @('/tmp/file')"


# 08ac0d97 'Fix parsing of redirect tokens (#5322)'
# Previous HEAD position was 7461c507 Fix incorrect IOREDIRECT tokens in Python mode (#5013)
# HEAD is now at 08ac0d97 Fix parsing of redirect tokens (#5322)
# xonsh/0.15.1
# Traceback (most recent call last):
#   File "/private/tmp/xonsh_dev/xonsh/xonsh/procs/specs.py", line 179, in safe_open
#     return open(fname, mode, buffering=buffering)
#            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
# TypeError: expected str, bytes or os.PathLike object, not NoneType
```
Hi @anki-code , thanks for investigating this issue! The reason why the `@()` operator outputs a list is that it can produce multiple arguments. For example:
```python
echo @(['hello','world'])
# Output: hello world

echo @(['a','b'])@(['c','d'])
# Output: ac ad bc bd
```

I don't think it's possible to fix this issue in the parser because in some cases we only know the length of the list at runtime:
```python
mylist = ['a']
echo hello > @(mylist) # should redirect output to file 'a'

mylist = ['a', 'b', 'c']
echo hello > @(mylist) # should produce an error since we cannot output to multiple files
```

Considering this, I think the fix that you provided in #5423 is probably the best solution.

Resolving patch:
diff --git a/xonsh/procs/specs.py b/xonsh/procs/specs.py
--- a/xonsh/procs/specs.py
+++ b/xonsh/procs/specs.py
@@ -223,6 +223,9 @@ def _parse_redirects(r, loc=None):
 
 def _redirect_streams(r, loc=None):
     """Returns stdin, stdout, stderr tuple of redirections."""
+    if isinstance(loc, list):
+        raise Exception(f"Unsupported redirect: {r!r} {loc!r}")
+
     stdin = stdout = stderr = None
     no_ampersand = r.replace("&", "")
     # special case of redirecting stderr to stdout
@@ -671,7 +674,21 @@ def resolve_args_list(self):
         """Weave a list of arguments into a command."""
         resolved_cmd = []
         for c in self.cmd:
-            resolved_cmd += c if isinstance(c, list) else [c]
+            if (
+                isinstance(c, tuple)
+                and len(c) == 2
+                and isinstance(c[1], list)
+                and len(c[1]) == 1
+            ):
+                # Redirect case e.g. `> file`
+                resolved_cmd.append(
+                    (
+                        c[0],
+                        c[1][0],
+                    )
+                )
+            else:
+                resolved_cmd += c if isinstance(c, list) else [c]
         self.cmd = resolved_cmd
 
     def resolve_redirects(self):


Test patch:
diff --git a/tests/procs/test_specs.py b/tests/procs/test_specs.py
--- a/tests/procs/test_specs.py
+++ b/tests/procs/test_specs.py
@@ -353,6 +353,14 @@ def my_handler(cmd, **kwargs):
     assert not fired
 
 
+def test_redirect_to_substitution(xession):
+    s = SubprocSpec.build(
+        # `echo hello > @('file')`
+        ["echo", "hello", (">", ["file"])]
+    )
+    assert s.stdout.name == "file"
+
+
 def test_partial_args_from_classmethod(xession):
     class Class:
         @classmethod
diff --git a/tests/test_integrations.py b/tests/test_integrations.py
--- a/tests/test_integrations.py
+++ b/tests/test_integrations.py
@@ -156,6 +156,34 @@ def _f(args, stdin, stdout, stderr):
         "The Truth is Out There\n",
         0,
     ),
+    # test redirecting to a python substitution
+    (
+        """
+def _f():
+    print('Wow Mom!')
+aliases['f'] = _f
+f > @('tttt')
+with open('tttt') as tttt:
+    s = tttt.read().strip()
+print('REDIRECTED OUTPUT: ' + s)
+""",
+        "REDIRECTED OUTPUT: Wow Mom!\n",
+        0,
+    ),
+    # test redirecting to a python substitution with p-string
+    (
+        """
+def _f():
+    print('Wow Mom!')
+aliases['f'] = _f
+f > @(p'tttt')
+with open('tttt') as tttt:
+    s = tttt.read().strip()
+print('REDIRECTED OUTPUT: ' + s)
+""",
+        "REDIRECTED OUTPUT: Wow Mom!\n",
+        0,
+    ),
     # test system exit in function alias
     (
         """
diff --git a/tests/test_pipelines.py b/tests/test_pipelines.py
--- a/tests/test_pipelines.py
+++ b/tests/test_pipelines.py
@@ -74,7 +74,6 @@ def patched_events(monkeypatch, xonsh_events, xonsh_session):
         ("!(echo hi | grep x)", "", "", ""),
     ),
 )
-@pytest.mark.flaky(reruns=3, reruns_delay=2)
 def test_command_pipeline_capture(cmdline, stdout, stderr, raw_stdout, xonsh_execer):
     pipeline: CommandPipeline = xonsh_execer.eval(cmdline)
     assert pipeline.out == stdout