Repository: beetbox/beets

Issue:
convert: `--pretend` option for `convert` command
convert should have a dry-run option showing the commands that are supposed to run



Discussion:
Indeed it should.

I'm downloading the source this weekend if this is still open, It seems a pretty easy task


Resolving patch:
diff --git a/beets/util/__init__.py b/beets/util/__init__.py
--- a/beets/util/__init__.py
+++ b/beets/util/__init__.py
@@ -24,6 +24,7 @@
 import traceback
 import subprocess
 import platform
+from sets import Set
 
 
 MAX_FILENAME_LENGTH = 200
@@ -201,10 +202,25 @@ def sorted_walk(path, ignore=(), logger=None):
             yield res
 
 
-def mkdirall(path):
+# We don't create directories on dry-runs, but we must pretend they exist
+directories_created = Set()
+
+
+def mkdirall(path, pretend=False):
     """Make all the enclosing directories of path (like mkdir -p on the
     parent).
     """
+
+    if pretend:
+        # directory = syspath(ancestry(path)[-1]) # "dirname"
+        # This seems cleaner but MAY have differences on symlinks (leading to
+        #  an equivalent result)
+        directory = os.path.dirname(path)
+        if directory not in directories_created:
+            directories_created.add(directory)
+            # This is not a "raw" translation, but it's brief one
+            print("mkdir -p '%s'" % (directory))
+        return
     for ancestor in ancestry(path):
         if not os.path.isdir(syspath(ancestor)):
             try:
@@ -388,10 +404,13 @@ def samefile(p1, p2):
     return shutil._samefile(syspath(p1), syspath(p2))
 
 
-def remove(path, soft=True):
+def remove(path, soft=True, pretend=False):
     """Remove the file. If `soft`, then no error will be raised if the
     file does not exist.
     """
+    if pretend:
+        print("rm '%s'" % (path))
+        return
     path = syspath(path)
     if soft and not os.path.exists(path):
         return
@@ -401,12 +420,15 @@ def remove(path, soft=True):
         raise FilesystemError(exc, 'delete', (path,), traceback.format_exc())
 
 
-def copy(path, dest, replace=False):
+def copy(path, dest, replace=False, pretend=False):
     """Copy a plain file. Permissions are not copied. If `dest` already
     exists, raises a FilesystemError unless `replace` is True. Has no
     effect if `path` is the same as `dest`. Paths are translated to
     system paths before the syscall.
     """
+    if pretend:
+        print("cp '%s' '%s'" % (path, dest))
+        return
     if samefile(path, dest):
         return
     path = syspath(path)
@@ -420,7 +442,7 @@ def copy(path, dest, replace=False):
                               traceback.format_exc())
 
 
-def move(path, dest, replace=False):
+def move(path, dest, replace=False, pretend=False):
     """Rename a file. `dest` may not be a directory. If `dest` already
     exists, raises an OSError unless `replace` is True. Has no effect if
     `path` is the same as `dest`. If the paths are on different
@@ -428,6 +450,9 @@ def move(path, dest, replace=False):
     instead, in which case metadata will *not* be preserved. Paths are
     translated to system paths.
     """
+    if pretend:
+        print("mv '%s' '%s'" % (path, dest))
+        return
     if samefile(path, dest):
         return
     path = syspath(path)
@@ -618,7 +643,7 @@ def cpu_count():
         return 1
 
 
-def command_output(cmd, shell=False):
+def command_output(cmd, shell=False, pretend=False):
     """Runs the command and returns its output after it has exited.
 
     ``cmd`` is a list of arguments starting with the command names.  If
@@ -633,6 +658,9 @@ def command_output(cmd, shell=False):
     Python 2.6 and which can have problems if lots of output is sent to
     stderr.
     """
+    if pretend:
+        print(cmd)
+        return
     with open(os.devnull, 'wb') as devnull:
         proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=devnull,
                                 close_fds=platform.system() != 'Windows',
diff --git a/beetsplug/convert.py b/beetsplug/convert.py
--- a/beetsplug/convert.py
+++ b/beetsplug/convert.py
@@ -81,7 +81,7 @@ def get_format(format=None):
     return (command.encode('utf8'), extension.encode('utf8'))
 
 
-def encode(command, source, dest):
+def encode(command, source, dest, pretend=False):
     """Encode `source` to `dest` using command template `command`.
 
     Raises `subprocess.CalledProcessError` if the command exited with a
@@ -89,7 +89,7 @@ def encode(command, source, dest):
     """
     quiet = config['convert']['quiet'].get()
 
-    if not quiet:
+    if not quiet and not pretend:
         log.info(u'Started encoding {0}'.format(util.displayable_path(source)))
 
     command = Template(command).safe_substitute({
@@ -101,7 +101,9 @@ def encode(command, source, dest):
               .format(util.displayable_path(command)))
 
     try:
-        util.command_output(command, shell=True)
+        util.command_output(command, shell=True, pretend=pretend)
+        if pretend:
+            return
     except subprocess.CalledProcessError:
         # Something went wrong (probably Ctrl+C), remove temporary files
         log.info(u'Encoding {0} failed. Cleaning up...'
@@ -114,7 +116,7 @@ def encode(command, source, dest):
             u"convert: could invoke '{0}': {0}".format(command, exc)
         )
 
-    if not quiet:
+    if not quiet and not pretend:
         log.info(u'Finished encoding {0}'.format(
             util.displayable_path(source))
         )
@@ -130,7 +132,8 @@ def should_transcode(item):
         item.bitrate >= 1000 * maxbr
 
 
-def convert_item(dest_dir, keep_new, path_formats, command, ext):
+def convert_item(dest_dir, keep_new, path_formats, command, ext,
+                 pretend=False):
     while True:
         item = yield
         dest = item.destination(basedir=dest_dir, path_formats=path_formats)
@@ -150,7 +153,7 @@ def convert_item(dest_dir, keep_new, path_formats, command, ext):
         # time. (The existence check is not atomic with the directory
         # creation inside this function.)
         with _fs_lock:
-            util.mkdirall(dest)
+            util.mkdirall(dest, pretend)
 
         if os.path.exists(util.syspath(dest)):
             log.info(u'Skipping {0} (target file exists)'.format(
@@ -161,18 +164,22 @@ def convert_item(dest_dir, keep_new, path_formats, command, ext):
         if keep_new:
             log.info(u'Moving to {0}'.
                      format(util.displayable_path(original)))
-            util.move(item.path, original)
+            util.move(item.path, original, pretend)
 
         if not should_transcode(item):
             # No transcoding necessary.
             log.info(u'Copying {0}'.format(util.displayable_path(item.path)))
-            util.copy(original, converted)
+            util.copy(original, converted, pretend)
         else:
             try:
-                encode(command, original, converted)
+                encode(command, original, converted, pretend)
             except subprocess.CalledProcessError:
                 continue
 
+        if pretend:
+            # Should we add support for tagging and after_convert plugins?
+            continue  # A yield is used at the start of the loop
+
         # Write tags from the database to the converted file.
         item.write(path=converted)
 
@@ -230,21 +237,27 @@ def convert_func(lib, opts, args):
 
     command, ext = get_format(opts.format)
 
-    ui.commands.list_items(lib, ui.decargs(args), opts.album, None)
+    pretend = opts.pretend if opts.pretend is not None else \
+        config['convert']['pretend'].get()
 
-    if not ui.input_yn("Convert? (Y/n)"):
-        return
+    if not pretend:
+        ui.commands.list_items(lib, ui.decargs(args), opts.album, None)
+
+        if not ui.input_yn("Convert? (Y/n)"):
+            return
 
     if opts.album:
         items = (i for a in lib.albums(ui.decargs(args)) for i in a.items())
     else:
         items = iter(lib.items(ui.decargs(args)))
-    convert_stages = []
-    for i in range(opts.threads):
-        convert_stages.append(
-            convert_item(opts.dest, opts.keep_new, path_formats, command, ext)
-        )
-    pipe = util.pipeline.Pipeline([items, convert_stages])
+    convert = [convert_item(opts.dest,
+                            opts.keep_new,
+                            path_formats,
+                            command,
+                            ext,
+                            pretend)
+               for _ in range(opts.threads)]
+    pipe = util.pipeline.Pipeline([items, convert])
     pipe.run_parallel()
 
 
@@ -253,6 +266,7 @@ def __init__(self):
         super(ConvertPlugin, self).__init__()
         self.config.add({
             u'dest': None,
+            u'pretend': False,
             u'threads': util.cpu_count(),
             u'format': u'mp3',
             u'formats': {
@@ -284,6 +298,8 @@ def __init__(self):
 
     def commands(self):
         cmd = ui.Subcommand('convert', help='convert to external location')
+        cmd.parser.add_option('-p', '--pretend', action='store_true',
+                              help='only show what would happen')
         cmd.parser.add_option('-a', '--album', action='store_true',
                               help='choose albums instead of tracks')
         cmd.parser.add_option('-t', '--threads', action='store', type='int',


Test patch: