Repository: qutebrowser/qutebrowser

Issue:
Support file pickers printing to stdout
> Also, wouldn't it be better to support file pickers who print to stdout instead of printing to a specific file???
> I'm also confused about the need for '{}', and what '{file}' even means. I changed the {} to {file} and it just created a file literally named '{file}'
> It seems this feature is centered around ranger, but what if I desire to use something like sxiv or dmenu? both of these print to stdout, and I'd love for it to be supported.

Split off from #6107, cc @emanrdesu @AcksID


Discussion:

Resolving patch:
diff --git a/qutebrowser/browser/shared.py b/qutebrowser/browser/shared.py
--- a/qutebrowser/browser/shared.py
+++ b/qutebrowser/browser/shared.py
@@ -23,7 +23,7 @@
 import sys
 import html
 import netrc
-from typing import Callable, Mapping, List
+from typing import Callable, Mapping, List, Optional
 import tempfile
 
 from PyQt5.QtCore import QUrl
@@ -360,23 +360,60 @@ def choose_file(multiple: bool) -> List[str]:
         A list of selected file paths, or empty list if no file is selected.
         If multiple is False, the return value will have at most 1 item.
     """
-    handle = tempfile.NamedTemporaryFile(prefix='qutebrowser-fileselect-', delete=False)
-    handle.close()
-    tmpfilename = handle.name
-    with utils.cleanup_file(tmpfilename):
-        if multiple:
-            command = config.val.fileselect.multiple_files.command
-        else:
-            command = config.val.fileselect.single_file.command
+    if multiple:
+        command = config.val.fileselect.multiple_files.command
+    else:
+        command = config.val.fileselect.single_file.command
+    use_tmp_file = any('{}' in arg for arg in command[1:])
+    if use_tmp_file:
+        handle = tempfile.NamedTemporaryFile(
+            prefix='qutebrowser-fileselect-',
+            delete=False,
+        )
+        handle.close()
+        tmpfilename = handle.name
+        with utils.cleanup_file(tmpfilename):
+            command = (
+                command[:1] +
+                [arg.replace('{}', tmpfilename) for arg in command[1:]]
+            )
+            return _execute_fileselect_command(
+                command=command,
+                multiple=multiple,
+                tmpfilename=tmpfilename,
+            )
+    else:
+        return _execute_fileselect_command(
+            command=command,
+            multiple=multiple,
+        )
 
-        proc = guiprocess.GUIProcess(what='choose-file')
-        proc.start(command[0],
-                   [arg.replace('{}', tmpfilename) for arg in command[1:]])
 
-        loop = qtutils.EventLoop()
-        proc.finished.connect(lambda _code, _status: loop.exit())
-        loop.exec()
+def _execute_fileselect_command(
+    command: List[str],
+    multiple: bool,
+    tmpfilename: Optional[str] = None
+) -> List[str]:
+    """Execute external command to choose file.
 
+    Args:
+        multiple: Should selecting multiple files be allowed.
+        tmpfilename: Path to the temporary file if used, otherwise None.
+
+    Return:
+        A list of selected file paths, or empty list if no file is selected.
+        If multiple is False, the return value will have at most 1 item.
+    """
+    proc = guiprocess.GUIProcess(what='choose-file')
+    proc.start(command[0], command[1:])
+
+    loop = qtutils.EventLoop()
+    proc.finished.connect(lambda _code, _status: loop.exit())
+    loop.exec()
+
+    if tmpfilename is None:
+        selected_files = proc.final_stdout.splitlines()
+    else:
         try:
             with open(tmpfilename, mode='r', encoding=sys.getfilesystemencoding()) as f:
                 selected_files = f.read().splitlines()
diff --git a/qutebrowser/misc/guiprocess.py b/qutebrowser/misc/guiprocess.py
--- a/qutebrowser/misc/guiprocess.py
+++ b/qutebrowser/misc/guiprocess.py
@@ -61,6 +61,9 @@ def __init__(self, what, *, verbose=False, additional_env=None,
         self.cmd = None
         self.args = None
 
+        self.final_stdout: str = ""
+        self.final_stderr: str = ""
+
         self._proc = QProcess(self)
         self._proc.errorOccurred.connect(self._on_error)
         self._proc.errorOccurred.connect(self.error)
@@ -125,6 +128,8 @@ def _on_finished(self, code, status):
                 log.procs.error("Process stderr:\n" + stderr.strip())
 
         qutescheme.spawn_output = self._spawn_format(exitinfo, stdout, stderr)
+        self.final_stdout = stdout
+        self.final_stderr = stderr
 
     def _spawn_format(self, exitinfo, stdout, stderr):
         """Produce a formatted string for spawn output."""
diff --git a/scripts/dev/run_vulture.py b/scripts/dev/run_vulture.py
--- a/scripts/dev/run_vulture.py
+++ b/scripts/dev/run_vulture.py
@@ -61,6 +61,7 @@ def whitelist_generator():  # noqa: C901
     yield 'scripts.utils.bg_colors'
     yield 'qutebrowser.misc.sql.SqliteErrorCode.CONSTRAINT'
     yield 'qutebrowser.misc.throttle.Throttle.set_delay'
+    yield 'qutebrowser.misc.guiprocess.GUIProcess.final_stderr'
 
     # Qt attributes
     yield 'PyQt5.QtWebKit.QWebPage.ErrorPageExtensionReturn().baseUrl'


Test patch:
diff --git a/tests/end2end/features/editor.feature b/tests/end2end/features/editor.feature
--- a/tests/end2end/features/editor.feature
+++ b/tests/end2end/features/editor.feature
@@ -6,14 +6,14 @@ Feature: Opening external editors
 
     Scenario: Editing a URL
         When I open data/numbers/1.txt
-        And I set up a fake editor replacing "1.txt" by "2.txt"
+        And I setup a fake editor replacing "1.txt" by "2.txt"
         And I run :edit-url
         Then data/numbers/2.txt should be loaded
 
     Scenario: Editing a URL with -t
         When I run :tab-only
         And I open data/numbers/1.txt
-        And I set up a fake editor replacing "1.txt" by "2.txt"
+        And I setup a fake editor replacing "1.txt" by "2.txt"
         And I run :edit-url -t
         Then data/numbers/2.txt should be loaded
         And the following tabs should be open:
@@ -24,7 +24,7 @@ Feature: Opening external editors
         When I set tabs.new_position.related to prev
         And I open data/numbers/1.txt
         And I run :tab-only
-        And I set up a fake editor replacing "1.txt" by "2.txt"
+        And I setup a fake editor replacing "1.txt" by "2.txt"
         And I run :edit-url -rt
         Then data/numbers/2.txt should be loaded
         And the following tabs should be open:
@@ -34,7 +34,7 @@ Feature: Opening external editors
     Scenario: Editing a URL with -b
         When I run :tab-only
         And I open data/numbers/1.txt
-        And I set up a fake editor replacing "1.txt" by "2.txt"
+        And I setup a fake editor replacing "1.txt" by "2.txt"
         And I run :edit-url -b
         Then data/numbers/2.txt should be loaded
         And the following tabs should be open:
@@ -45,7 +45,7 @@ Feature: Opening external editors
         When I run :window-only
         And I open data/numbers/1.txt in a new tab
         And I run :tab-only
-        And I set up a fake editor replacing "1.txt" by "2.txt"
+        And I setup a fake editor replacing "1.txt" by "2.txt"
         And I run :edit-url -w
         Then data/numbers/2.txt should be loaded
         And the session should look like:
@@ -65,7 +65,7 @@ Feature: Opening external editors
         When I open data/numbers/1.txt in a new tab
         And I run :tab-only
         And I run :window-only
-        And I set up a fake editor replacing "1.txt" by "2.txt"
+        And I setup a fake editor replacing "1.txt" by "2.txt"
         And I run :edit-url -p
         Then data/numbers/2.txt should be loaded
         And the session should look like:
@@ -90,13 +90,13 @@ Feature: Opening external editors
     Scenario: Editing a URL with invalid URL
         When I set url.auto_search to never
         And I open data/hello.txt
-        And I set up a fake editor replacing "http://localhost:(port)/data/hello.txt" by "foo!"
+        And I setup a fake editor replacing "http://localhost:(port)/data/hello.txt" by "foo!"
         And I run :edit-url
         Then the error "Invalid URL" should be shown
 
     Scenario: Spawning an editor successfully
         Given I have a fresh instance
-        When I set up a fake editor returning "foobar"
+        When I setup a fake editor returning "foobar"
         And I open data/editor.html
         And I run :click-element id qute-textarea
         And I wait for "Entering mode KeyMode.insert (reason: clicking input)" in the log
@@ -105,7 +105,7 @@ Feature: Opening external editors
         Then the javascript message "text: foobar" should be logged
 
     Scenario: Spawning an editor in normal mode
-        When I set up a fake editor returning "foobar"
+        When I setup a fake editor returning "foobar"
         And I open data/editor.html
         And I run :click-element id qute-textarea
         And I wait for "Entering mode KeyMode.insert (reason: clicking input)" in the log
@@ -119,7 +119,7 @@ Feature: Opening external editors
     # There's no guarantee that the tab gets deleted...
     @posix
     Scenario: Spawning an editor and closing the tab
-        When I set up a fake editor that writes "foobar" on save
+        When I setup a fake editor that writes "foobar" on save
         And I open data/editor.html
         And I run :click-element id qute-textarea
         And I wait for "Entering mode KeyMode.insert (reason: clicking input)" in the log
@@ -134,7 +134,7 @@ Feature: Opening external editors
     # Could not get signals working on Windows
     @posix
     Scenario: Spawning an editor and saving
-        When I set up a fake editor that writes "foobar" on save
+        When I setup a fake editor that writes "foobar" on save
         And I open data/editor.html
         And I run :click-element id qute-textarea
         And I wait for "Entering mode KeyMode.insert (reason: clicking input)" in the log
@@ -145,7 +145,7 @@ Feature: Opening external editors
         Then the javascript message "text: foobar" should be logged
 
     Scenario: Spawning an editor in caret mode
-        When I set up a fake editor returning "foobar"
+        When I setup a fake editor returning "foobar"
         And I open data/editor.html
         And I run :click-element id qute-textarea
         And I wait for "Entering mode KeyMode.insert (reason: clicking input)" in the log
@@ -159,7 +159,7 @@ Feature: Opening external editors
         Then the javascript message "text: foobar" should be logged
 
     Scenario: Spawning an editor with existing text
-        When I set up a fake editor replacing "foo" by "bar"
+        When I setup a fake editor replacing "foo" by "bar"
         And I open data/editor.html
         And I run :click-element id qute-textarea
         And I wait for "Entering mode KeyMode.insert (reason: clicking input)" in the log
@@ -173,20 +173,20 @@ Feature: Opening external editors
 
     Scenario: Edit a command and run it
         When I run :set-cmd-text :message-info foo
-        And I set up a fake editor replacing "foo" by "bar"
+        And I setup a fake editor replacing "foo" by "bar"
         And I run :edit-command --run
         Then the message "bar" should be shown
         And "Leaving mode KeyMode.command (reason: cmd accept)" should be logged
 
     Scenario: Edit a command and omit the start char
-        When I set up a fake editor returning "message-info foo"
+        When I setup a fake editor returning "message-info foo"
         And I run :edit-command
         Then the error "command must start with one of :/?" should be shown
         And "Leaving mode KeyMode.command *" should not be logged
 
     Scenario: Edit a command to be empty
         When I run :set-cmd-text :
-        When I set up a fake editor returning empty text
+        When I setup a fake editor returning empty text
         And I run :edit-command
         Then the error "command must start with one of :/?" should be shown
         And "Leaving mode KeyMode.command *" should not be logged
@@ -194,13 +194,20 @@ Feature: Opening external editors
     ## select single file
 
     Scenario: Select one file with single command
-        When I set up a fake "single_file" fileselector selecting "tests/end2end/data/numbers/1.txt"
+        When I setup a fake single_file fileselector selecting "tests/end2end/data/numbers/1.txt" and writes to a temporary file
+        And I open data/fileselect.html
+        And I run :click-element id single_file
+        Then the javascript message "Files: 1.txt" should be logged
+
+    Scenario: Select one file with single command that writes to stdout
+        When I setup a fake single_file fileselector selecting "tests/end2end/data/numbers/1.txt" and writes to stdout
         And I open data/fileselect.html
         And I run :click-element id single_file
         Then the javascript message "Files: 1.txt" should be logged
 
     Scenario: Select two files with single command
-        When I set up a fake "single_file" fileselector selecting "tests/end2end/data/numbers/1.txt tests/end2end/data/numbers/2.txt"
+        When I setup a fake single_file fileselector selecting "tests/end2end/data/numbers/1.txt tests/end2end/data/numbers/2.txt" and writes to a temporary file
+
         And I open data/fileselect.html
         And I run :click-element id single_file
         Then the javascript message "Files: 1.txt" should be logged
@@ -209,13 +216,15 @@ Feature: Opening external editors
     ## select multiple files
 
     Scenario: Select one file with multiple command
-        When I set up a fake "multiple_files" fileselector selecting "tests/end2end/data/numbers/1.txt"
+        When I setup a fake multiple_files fileselector selecting "tests/end2end/data/numbers/1.txt" and writes to a temporary file
+
         And I open data/fileselect.html
         And I run :click-element id multiple_files
         Then the javascript message "Files: 1.txt" should be logged
 
     Scenario: Select two files with multiple command
-        When I set up a fake "multiple_files" fileselector selecting "tests/end2end/data/numbers/1.txt tests/end2end/data/numbers/2.txt"
+        When I setup a fake multiple_files fileselector selecting "tests/end2end/data/numbers/1.txt tests/end2end/data/numbers/2.txt" and writes to a temporary file
+
         And I open data/fileselect.html
         And I run :click-element id multiple_files
         Then the javascript message "Files: 1.txt, 2.txt" should be logged
diff --git a/tests/end2end/features/test_editor_bdd.py b/tests/end2end/features/test_editor_bdd.py
--- a/tests/end2end/features/test_editor_bdd.py
+++ b/tests/end2end/features/test_editor_bdd.py
@@ -32,7 +32,7 @@
 from qutebrowser.utils import utils
 
 
-@bdd.when(bdd.parsers.parse('I set up a fake editor replacing "{text}" by '
+@bdd.when(bdd.parsers.parse('I setup a fake editor replacing "{text}" by '
                             '"{replacement}"'))
 def set_up_editor_replacement(quteproc, server, tmpdir, text, replacement):
     """Set up editor.command to a small python script doing a replacement."""
@@ -53,7 +53,7 @@ def set_up_editor_replacement(quteproc, server, tmpdir, text, replacement):
     quteproc.set_setting('editor.command', editor)
 
 
-@bdd.when(bdd.parsers.parse('I set up a fake editor returning "{text}"'))
+@bdd.when(bdd.parsers.parse('I setup a fake editor returning "{text}"'))
 def set_up_editor(quteproc, tmpdir, text):
     """Set up editor.command to a small python script inserting a text."""
     script = tmpdir / 'script.py'
@@ -67,7 +67,7 @@ def set_up_editor(quteproc, tmpdir, text):
     quteproc.set_setting('editor.command', editor)
 
 
-@bdd.when(bdd.parsers.parse('I set up a fake editor returning empty text'))
+@bdd.when(bdd.parsers.parse('I setup a fake editor returning empty text'))
 def set_up_editor_empty(quteproc, tmpdir):
     """Set up editor.command to a small python script inserting empty text."""
     set_up_editor(quteproc, tmpdir, "")
@@ -107,7 +107,7 @@ def editor_pid_watcher(tmpdir):
     return EditorPidWatcher(tmpdir)
 
 
-@bdd.when(bdd.parsers.parse('I set up a fake editor that writes "{text}" on '
+@bdd.when(bdd.parsers.parse('I setup a fake editor that writes "{text}" on '
                             'save'))
 def set_up_editor_wait(quteproc, tmpdir, text, editor_pid_watcher):
     """Set up editor.command to a small python script inserting a text."""
@@ -180,18 +180,31 @@ def save_editor_wait(tmpdir):
     os.kill(pid, signal.SIGUSR2)
 
 
-@bdd.when(bdd.parsers.parse('I set up a fake "{kind}" fileselector '
-                            'selecting "{files}"'))
-def set_up_fileselector(quteproc, py_proc, kind, files):
+@bdd.when(bdd.parsers.parse('I setup a fake {kind} fileselector '
+                            'selecting "{files}" and writes to {output_type}'))
+def set_up_fileselector(quteproc, py_proc, kind, files, output_type):
     """Set up fileselect.xxx.command to select the file(s)."""
     cmd, args = py_proc(r"""
         import os
         import sys
-        tmp_file = sys.argv[1]
-        with open(tmp_file, 'w') as f:
-            for selected_file in sys.argv[2:]:
-                f.write(os.path.abspath(selected_file) + "\n")
+        tmp_file = None
+        for i, arg in enumerate(sys.argv):
+            if arg.startswith('--file='):
+                tmp_file = arg[len('--file='):]
+                sys.argv.pop(i)
+                break
+        selected_files = sys.argv[1:]
+        if tmp_file is None:
+            for selected_file in selected_files:
+                print(os.path.abspath(selected_file))
+        else:
+            with open(tmp_file, 'w') as f:
+                for selected_file in selected_files:
+                    f.write(os.path.abspath(selected_file) + '\n')
     """)
-    fileselect_cmd = json.dumps([cmd, *args, '{}', *files.split(' ')])
+    args += files.split(' ')
+    if output_type == "a temporary file":
+        args += ['--file={}']
+    fileselect_cmd = json.dumps([cmd, *args])
     quteproc.set_setting('fileselect.handler', 'external')
     quteproc.set_setting(f'fileselect.{kind}.command', fileselect_cmd)
diff --git a/tests/unit/misc/test_guiprocess.py b/tests/unit/misc/test_guiprocess.py
--- a/tests/unit/misc/test_guiprocess.py
+++ b/tests/unit/misc/test_guiprocess.py
@@ -127,9 +127,11 @@ def test_start_output_message(proc, qtbot, caplog, message_mock, py_proc,
     if stdout_msg is not None:
         assert stdout_msg.level == usertypes.MessageLevel.info
         assert stdout_msg.text == 'stdout text'
+        assert proc.final_stdout.strip() == "stdout text", proc.final_stdout
     if stderr_msg is not None:
         assert stderr_msg.level == usertypes.MessageLevel.error
         assert stderr_msg.text == 'stderr text'
+        assert proc.final_stderr.strip() == "stderr text", proc.final_stderr
 
 
 def test_start_env(monkeypatch, qtbot, py_proc):