Repository: qutebrowser/qutebrowser

Issue:
Create non-existing directories for downloads
A path of `/tmp/foobar/filename` should download to `filename` in `/tmp/foobar` even if that doesn't exist.

A path of `/tmp/foobar/` (with a trailing slash) should download to `/tmp/foobar/suggested_filename.foo` even if `/tmp/foobar` doesn't exist.

To avoid creating directories on typos we could also display a prompt first.


Discussion:

Resolving patch:
diff --git a/qutebrowser/browser/downloads.py b/qutebrowser/browser/downloads.py
--- a/qutebrowser/browser/downloads.py
+++ b/qutebrowser/browser/downloads.py
@@ -137,7 +137,8 @@ def create_full_filename(basename, filename):
     encoding = sys.getfilesystemencoding()
     filename = utils.force_encoding(filename, encoding)
     basename = utils.force_encoding(basename, encoding)
-    if os.path.isabs(filename) and os.path.isdir(filename):
+    if os.path.isabs(filename) and (os.path.isdir(filename) or
+                                    filename.endswith(os.sep)):
         # We got an absolute directory from the user, so we save it under
         # the default filename in that directory.
         return os.path.join(filename, basename)
@@ -604,6 +605,11 @@ def _ask_confirm_question(self, title, msg):
         """Ask a confirmation question for the download."""
         raise NotImplementedError
 
+    def _ask_create_parent_question(self, title, msg,
+                                    force_overwrite, remember_directory):
+        """Ask a confirmation question for the parent directory."""
+        raise NotImplementedError
+
     def _set_fileobj(self, fileobj, *, autoclose=True):
         """Set a file object to save the download to.
 
@@ -630,7 +636,6 @@ def _set_filename(self, filename, *, force_overwrite=False,
             remember_directory: If True, remember the directory for future
                                 downloads.
         """
-        global last_used_directory
         filename = os.path.expanduser(filename)
         self._ensure_can_set_filename(filename)
 
@@ -657,11 +662,41 @@ def _set_filename(self, filename, *, force_overwrite=False,
             self._filename = create_full_filename(self.basename,
                                                   os.path.expanduser('~'))
 
+        dirname = os.path.dirname(self._filename)
+        if not os.path.exists(dirname):
+            txt = ("<b>{}</b> does not exist. Create it?".
+                   format(html.escape(
+                       os.path.join(dirname, ""))))
+            self._ask_create_parent_question("Create directory?", txt,
+                                             force_overwrite,
+                                             remember_directory)
+        else:
+            self._after_create_parent_question(force_overwrite,
+                                               remember_directory)
+
+    def _after_create_parent_question(self,
+                                      force_overwrite, remember_directory):
+        """After asking about parent directory.
+
+        Args:
+            force_overwrite: Force overwriting existing files.
+            remember_directory: If True, remember the directory for future
+                                downloads.
+        """
+        global last_used_directory
+
+        try:
+            os.makedirs(os.path.dirname(self._filename))
+        except FileExistsError:
+            pass
+        except OSError as e:
+            self._die(e.strerror)
+
         self.basename = os.path.basename(self._filename)
         if remember_directory:
             last_used_directory = os.path.dirname(self._filename)
 
-        log.downloads.debug("Setting filename to {}".format(filename))
+        log.downloads.debug("Setting filename to {}".format(self._filename))
         if force_overwrite:
             self._after_set_filename()
         elif os.path.isfile(self._filename):
diff --git a/qutebrowser/browser/qtnetworkdownloads.py b/qutebrowser/browser/qtnetworkdownloads.py
--- a/qutebrowser/browser/qtnetworkdownloads.py
+++ b/qutebrowser/browser/qtnetworkdownloads.py
@@ -203,6 +203,17 @@ def _ask_confirm_question(self, title, msg):
                               no_action=no_action, cancel_action=no_action,
                               abort_on=[self.cancelled, self.error])
 
+    def _ask_create_parent_question(self, title, msg,
+                                    force_overwrite, remember_directory):
+        no_action = functools.partial(self.cancel, remove_data=False)
+        message.confirm_async(title=title, text=msg,
+                              yes_action=(lambda:
+                                          self._after_create_parent_question(
+                                              force_overwrite,
+                                              remember_directory)),
+                              no_action=no_action, cancel_action=no_action,
+                              abort_on=[self.cancelled, self.error])
+
     def _set_fileobj(self, fileobj, *, autoclose=True):
         """"Set the file object to write the download to.
 
diff --git a/qutebrowser/browser/webengine/webenginedownloads.py b/qutebrowser/browser/webengine/webenginedownloads.py
--- a/qutebrowser/browser/webengine/webenginedownloads.py
+++ b/qutebrowser/browser/webengine/webenginedownloads.py
@@ -133,6 +133,22 @@ def _ask_confirm_question(self, title, msg):
         self.error.connect(question.abort)
         message.global_bridge.ask(question, blocking=True)
 
+    def _ask_create_parent_question(self, title, msg,
+                                    force_overwrite, remember_directory):
+        no_action = functools.partial(self.cancel, remove_data=False)
+        question = usertypes.Question()
+        question.title = title
+        question.text = msg
+        question.mode = usertypes.PromptMode.yesno
+        question.answered_yes.connect(lambda:
+                                      self._after_create_parent_question(
+                                          force_overwrite, remember_directory))
+        question.answered_no.connect(no_action)
+        question.cancelled.connect(no_action)
+        self.cancelled.connect(question.abort)
+        self.error.connect(question.abort)
+        message.global_bridge.ask(question, blocking=True)
+
     def _after_set_filename(self):
         self._qt_item.setPath(self._filename)
         self._qt_item.accept()


Test patch:
diff --git a/tests/end2end/features/downloads.feature b/tests/end2end/features/downloads.feature
--- a/tests/end2end/features/downloads.feature
+++ b/tests/end2end/features/downloads.feature
@@ -255,9 +255,19 @@ Feature: Downloading things from a website.
         When I run :download --mhtml http://foobar/
         Then the error "Can only download the current page as mhtml." should be shown
 
+    Scenario: :download with a filename and directory which doesn't exist
+        When I run :download --dest (tmpdir)(dirsep)downloads(dirsep)somedir(dirsep)file http://localhost:(port)/data/downloads/download.bin
+        And I wait for "Asking question <qutebrowser.utils.usertypes.Question default=None mode=<PromptMode.yesno: 1> text='<b>*</b> does not exist. Create it?' title='Create directory?'>, *" in the log
+        And I run :prompt-accept yes
+        And I wait until the download is finished
+        Then the downloaded file somedir/file should exist
+
     Scenario: :download with a directory which doesn't exist
-        When I run :download --dest (tmpdir)/downloads/somedir/filename http://localhost:(port)/
-        Then the error "Download error: No such file or directory" should be shown
+        When I run :download --dest (tmpdir)(dirsep)downloads(dirsep)somedir(dirsep) http://localhost:(port)/data/downloads/download.bin
+        And I wait for "Asking question <qutebrowser.utils.usertypes.Question default=None mode=<PromptMode.yesno: 1> text='<b>*</b> does not exist. Create it?' title='Create directory?'>, *" in the log
+        And I run :prompt-accept yes
+        And I wait until the download is finished
+        Then the downloaded file somedir/download.bin should exist
 
     ## mhtml downloads