diff --git a/beet b/beet index 0a4a0adfc..11ac9b318 100755 --- a/beet +++ b/beet @@ -14,168 +14,50 @@ # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. -import cmdln import ConfigParser import os import sys +import optparse from beets import ui from beets import Library -CONFIG_DEFAULTS = { - 'beets': { - 'library': '~/.beetsmusic.blb', - 'directory': '~/Music', - 'path_format': '$artist/$album/$track $title', - 'import_copy': True, - 'import_write': True, - }, - - 'bpd': { - 'host': '', - 'port': '6600', - 'password': '', - }, -} - CONFIG_FILE = os.path.expanduser('~/.beetsconfig') -def make_query(criteria): - """Make query string for the list of criteria.""" - return ' '.join(criteria).strip() or None - -class BeetsApp(cmdln.Cmdln): - name = "beet" - - def get_optparser(self): - # Add global options to the command. - parser = cmdln.Cmdln.get_optparser(self) - parser.add_option('-l', '--library', dest='libpath', - help='library database file to use') - parser.add_option('-d', '--directory', dest='directory', - help="destination music directory") - parser.add_option('-p', '--pathformat', dest='path_format', - help="destination path format string") - parser.add_option('-i', '--device', dest='device', - help="name of the device library to use") - return parser - - def postoptparse(self): - # Read defaults from config file. - self.config = ConfigParser.SafeConfigParser() - self.config.read(CONFIG_FILE) - for sec in CONFIG_DEFAULTS: - if not self.config.has_section(sec): - self.config.add_section(sec) - - # Open library file. - if self.options.device: - from beets.device import PodLibrary - self.lib = PodLibrary.by_name(self.options.device) - else: - libpath = self.options.libpath or \ - self._cfg_get('beets', 'library') - directory = self.options.directory or \ - self._cfg_get('beets', 'directory') - path_format = self.options.path_format or \ - self._cfg_get('beets', 'path_format') - self.lib = Library(os.path.expanduser(libpath), - directory, - path_format) - - def _cfg_get(self, section, name, vtype=None): - try: - if vtype is bool: - return self.config.getboolean(section, name) - else: - return self.config.get(section, name) - except ConfigParser.NoOptionError: - return CONFIG_DEFAULTS[section][name] - - @cmdln.alias("imp", "im") - @cmdln.option('-c', '--copy', action='store_true', default=None, - help="copy tracks into library directory (default)") - @cmdln.option('-C', '--nocopy', action='store_false', dest='copy', - help="don't copy tracks (opposite of -c)") - @cmdln.option('-w', '--write', action='store_true', default=None, - help="write new metadata to files' tags (default)") - @cmdln.option('-W', '--nowrite', action='store_false', dest='write', - help="don't write metadata (opposite of -s)") - @cmdln.option('-a', '--autotag', action='store_true', dest='autotag', - help="infer tags for imported files (default)") - @cmdln.option('-A', '--noautotag', action='store_false', dest='autotag', - help="don't infer tags for imported files (opposite of -a)") - @cmdln.option('-l', '--log', dest='logpath', - help='file to log untaggable albums for later review') - def do_import(self, subcmd, opts, *paths): - """${cmd_name}: import new music - - ${cmd_usage} - ${cmd_option_list} - """ - copy = opts.copy if opts.copy is not None else \ - self._cfg_get('beets', 'import_copy', bool) - write = opts.write if opts.write is not None else \ - self._cfg_get('beets', 'import_write', bool) - autot = opts.autotag if opts.autotag is not None else True - ui.import_files(self.lib, paths, copy, write, autot, opts.logpath) - - @cmdln.alias("ls") - @cmdln.option('-a', '--album', action='store_true', - help='show matching albums instead of tracks') - def do_list(self, subcmd, opts, *criteria): - """${cmd_name}: query the library - - ${cmd_usage} - ${cmd_option_list} - """ - ui.list_items(self.lib, make_query(criteria), opts.album) - - @cmdln.alias("rm") - @cmdln.option("-d", "--delete", action="store_true", - help="also remove files from disk") - @cmdln.option('-a', '--album', action='store_true', - help='match albums instead of tracks') - def do_remove(self, subcmd, opts, *criteria): - """${cmd_name}: remove matching items from the library - - ${cmd_usage} - ${cmd_option_list} - """ - q = make_query(criteria) - ui.remove_items(self.lib, make_query(criteria), - opts.album, opts.delete) - - @cmdln.option('-d', '--debug', action='store_true', - help='dump all MPD traffic to stdout') - def do_bpd(self, subcmd, opts, host=None, port=None): - """${cmd_name}: run an MPD-compatible music player server - - ${cmd_usage} - ${cmd_option_list} - """ - host = host or self._cfg_get('bpd', 'host') - port = port or self._cfg_get('bpd', 'port') - password = self._cfg_get('bpd', 'password') - debug = opts.debug or False - ui.start_bpd(self.lib, host, int(port), password, debug) - - def do_dadd(self, subcmd, opts, name, *criteria): - """${cmd_name}: add files to a device - - ${cmd_usage} - ${cmd_option_list} - """ - ui.device_add(self.lib, make_query(criteria), name) - - def do_stats(self, subcmd, opts, *criteria): - """${cmd_name}: show statistics about the library or a query - - ${cmd_usage} - ${cmd_option_list} - """ - ui.show_stats(self.lib, make_query(criteria)) +parser = ui.SubcommandsOptionParser(subcommands=ui.default_subcommands) +parser.add_option('-l', '--library', dest='libpath', + help='library database file to use') +parser.add_option('-d', '--directory', dest='directory', + help="destination music directory") +parser.add_option('-p', '--pathformat', dest='path_format', + help="destination path format string") +parser.add_option('-i', '--device', dest='device', + help="name of the device library to use") if __name__ == '__main__': - app = BeetsApp() - sys.exit(app.main()) + options, subcommand, suboptions, subargs = parser.parse_args() + + # Read defaults from config file. + config = ConfigParser.SafeConfigParser() + config.read(CONFIG_FILE) + for sec in ui.CONFIG_DEFAULTS: + if not config.has_section(sec): + config.add_section(sec) + + # Open library file. + if options.device: + from beets.device import PodLibrary + lib = PodLibrary.by_name(self.options.device) + else: + libpath = options.libpath or \ + ui._cfg_get(config, 'beets', 'library') + directory = options.directory or \ + ui._cfg_get(config, 'beets', 'directory') + path_format = options.path_format or \ + ui._cfg_get(config, 'beets', 'path_format') + lib = Library(os.path.expanduser(libpath), + directory, + path_format) + + # XXX + subcommand.func(lib, config, suboptions, subargs) diff --git a/beets/ui.py b/beets/ui.py index 883ddff23..9958c9dc2 100644 --- a/beets/ui.py +++ b/beets/ui.py @@ -15,6 +15,9 @@ import os import logging import locale +import optparse +import textwrap +import ConfigParser from beets import autotag from beets import library @@ -24,6 +27,7 @@ from beets.player import bpd # Global logger. log = logging.getLogger('beets') + # Utilities. def _print(txt=''): @@ -128,6 +132,164 @@ def _human_seconds(interval): return "%3.1f %ss" % (interval, suffix) +# Subcommand parsing infrastructure. + +# This is a fairly generic subcommand parser for optparse. It is +# maintained externally here: +# http://gist.github.com/462717 +# There you will also find a better description of the code and a more +# succinct example program. + +class Subcommand(object): + """A subcommand of a root command-line application that may be + invoked by a SubcommandOptionParser. + """ + def __init__(self, name, parser, help='', aliases=()): + """Creates a new subcommand. name is the primary way to invoke + the subcommand; aliases are alternate names. parser is an + OptionParser responsible for parsing the subcommand's options. + help is a short description of the command.""" + self.name = name + self.parser = parser + self.aliases = aliases + self.help = help + +class SubcommandsOptionParser(optparse.OptionParser): + """A variant of OptionParser that parses subcommands and their + arguments. + """ + + # A singleton command used to give help on other subcommands. + _HelpSubcommand = Subcommand('help', optparse.OptionParser(), + help='give detailed help on a specific sub-command', + aliases=('?',)) + + def __init__(self, *args, **kwargs): + """Create a new subcommand-aware option parser. All of the + options to OptionParser.__init__ are supported in addition + to subcommands, a sequence of Subcommand objects. + """ + # The subcommand array, with the help command included. + self.subcommands = list(kwargs.pop('subcommands', [])) + self.subcommands.append(self._HelpSubcommand) + + # A more helpful default usage. + if 'usage' not in kwargs: + kwargs['usage'] = """ + %prog COMMAND [ARGS...] + %prog help COMMAND""" + + # Super constructor. + optparse.OptionParser.__init__(self, *args, **kwargs) + + # Adjust the help-visible name of each subcommand. + for subcommand in self.subcommands: + subcommand.parser.prog = '%s %s' % \ + (self.get_prog_name(), subcommand.name) + + # Our root parser needs to stop on the first unrecognized argument. + self.disable_interspersed_args() + + # Add the list of subcommands to the help message. + def format_help(self, formatter=None): + # Get the original help message, to which we will append. + out = optparse.OptionParser.format_help(self, formatter) + if formatter is None: + formatter = self.formatter + + # Subcommands header. + result = ["\n"] + result.append(formatter.format_heading('Commands')) + formatter.indent() + + # Generate the display names (including aliases). + # Also determine the help position. + disp_names = [] + help_position = 0 + for subcommand in self.subcommands: + name = subcommand.name + if subcommand.aliases: + name += ' (%s)' % ', '.join(subcommand.aliases) + disp_names.append(name) + + # Set the help position based on the max width. + proposed_help_position = len(name) + formatter.current_indent + 2 + if proposed_help_position <= formatter.max_help_position: + help_position = max(help_position, proposed_help_position) + + # Add each subcommand to the output. + for subcommand, name in zip(self.subcommands, disp_names): + # Lifted directly from optparse.py. + name_width = help_position - formatter.current_indent - 2 + if len(name) > name_width: + name = "%*s%s\n" % (formatter.current_indent, "", name) + indent_first = help_position + else: + name = "%*s%-*s " % (formatter.current_indent, "", + name_width, name) + indent_first = 0 + result.append(name) + help_width = formatter.width - help_position + help_lines = textwrap.wrap(subcommand.help, help_width) + result.append("%*s%s\n" % (indent_first, "", help_lines[0])) + result.extend(["%*s%s\n" % (help_position, "", line) + for line in help_lines[1:]]) + formatter.dedent() + + # Concatenate the original help message with the subcommand + # list. + return out + "".join(result) + + def _subcommand_for_name(self, name): + """Return the subcommand in self.subcommands matching the + given name. The name may either be the name of a subcommand or + an alias. If no subcommand matches, returns None. + """ + for subcommand in self.subcommands: + if name == subcommand.name or \ + name in subcommand.aliases: + return subcommand + return None + + def parse_args(self, a=None, v=None): + """Like OptionParser.parse_args, but returns these four items: + - options: the options passed to the root parser + - subcommand: the Subcommand object that was invoked + - suboptions: the options passed to the subcommand parser + - subargs: the positional arguments passed to the subcommand + """ + options, args = optparse.OptionParser.parse_args(self, a, v) + + if not args: + # No command given. + self.print_help() + self.exit() + else: + cmdname = args.pop(0) + if cmdname.startswith('-'): + parser.error('unknown option ' + cmdname) + else: + subcommand = self._subcommand_for_name(cmdname) + if not subcommand: + parser.error('unknown command ' + cmdname) + + suboptions, subargs = subcommand.parser.parse_args(args) + + if subcommand is self._HelpSubcommand: + if subargs: + # particular + cmdname = subargs[0] + helpcommand = self._subcommand_for_name(cmdname) + helpcommand.parser.print_help() + self.exit() + else: + # general + self.print_help() + self.exit() + + return options, subcommand, suboptions, subargs + + # Autotagging interface. def show_change(cur_artist, cur_album, items, info, dist): @@ -499,3 +661,114 @@ Albums: %i""" % ( _human_bytes(total_size), len(artists), len(albums) )) + + +# XXX +CONFIG_DEFAULTS = { + 'beets': { + 'library': '~/.beetsmusic.blb', + 'directory': '~/Music', + 'path_format': '$artist/$album/$track $title', + 'import_copy': True, + 'import_write': True, + }, + + 'bpd': { + 'host': '', + 'port': '6600', + 'password': '', + }, +} +def _cfg_get(config, section, name, vtype=None): + try: + if vtype is bool: + return config.getboolean(section, name) + else: + return config.get(section, name) + except ConfigParser.NoOptionError: + return CONFIG_DEFAULTS[section][name] +def make_query(criteria): + """Make query string for the list of criteria.""" + return ' '.join(criteria).strip() or None + + + +# Default subcommands. + +default_subcommands = [] + +import_cmd = Subcommand('import', optparse.OptionParser(), + 'import new music', ('imp', 'im')) +import_cmd.parser.add_option('-c', '--copy', action='store_true', + default=None, help="copy tracks into library directory (default)") +import_cmd.parser.add_option('-C', '--nocopy', action='store_false', + dest='copy', help="don't copy tracks (opposite of -c)") +import_cmd.parser.add_option('-w', '--write', action='store_true', + default=None, help="write new metadata to files' tags (default)") +import_cmd.parser.add_option('-W', '--nowrite', action='store_false', + dest='write', help="don't write metadata (opposite of -s)") +import_cmd.parser.add_option('-a', '--autotag', action='store_true', + dest='autotag', help="infer tags for imported files (default)") +import_cmd.parser.add_option('-A', '--noautotag', action='store_false', + dest='autotag', + help="don't infer tags for imported files (opposite of -a)") +import_cmd.parser.add_option('-l', '--log', dest='logpath', + help='file to log untaggable albums for later review') +def import_func(lib, config, opts, args): + copy = opts.copy if opts.copy is not None else \ + self._cfg_get('beets', 'import_copy', bool) + write = opts.write if opts.write is not None else \ + self._cfg_get('beets', 'import_write', bool) + autot = opts.autotag if opts.autotag is not None else True + import_files(lib, args, copy, write, autot, opts.logpath) +import_cmd.func = import_func +default_subcommands.append(import_cmd) + +list_cmd = Subcommand('list', optparse.OptionParser(), + 'query the library', ('ls',)) +list_cmd.parser.add_option('-a', '--album', action='store_true', + help='show matching albums instead of tracks') +def list_func(lib, config, opts, args): + list_items(lib, make_query(args), opts.album) +list_cmd.func = list_func +default_subcommands.append(list_cmd) + +remove_cmd = Subcommand('remove', optparse.OptionParser(), + 'remove matching items from the library', ('rm',)) +remove_cmd.parser.add_option("-d", "--delete", action="store_true", + help="also remove files from disk") +remove_cmd.parser.add_option('-a', '--album', action='store_true', + help='match albums instead of tracks') +def remove_func(lib, config, opts, args): + remove_items(lib, make_query(args), opts.album, opts.delete) +remove_cmd.func = remove_func +default_subcommands.append(remove_cmd) + +bpd_cmd = Subcommand('bpd', optparse.OptionParser(), + 'run an MPD-compatible music player server') +bpd_cmd.parser.add_option('-d', '--debug', action='store_true', + help='dump all MPD traffic to stdout') +def bpd_func(lib, config, opts, args): + host = args.pop(0) if args else _cfg_get(config, 'bpd', 'host') + port = args.pop(0) if args else _cfg_get(config, 'bpd', 'port') + password = _cfg_get(config, 'bpd', 'password') + debug = opts.debug or False + start_bpd(lib, host, int(port), password, debug) +bpd_cmd.func = bpd_func +default_subcommands.append(bpd_cmd) + +dadd_cmd = Subcommand('dadd', optparse.OptionParser(), + 'add files to a device') +def dadd_func(lib, config, opts, args): + name = args.pop(0) + # fixme require exactly one arg + device_add(lib, make_query(args), name) +dadd_cmd.func = dadd_func +default_subcommands.append(dadd_cmd) + +stats_cmd = Subcommand('stats', optparse.OptionParser(), + 'show statistics about the library or a query') +def stats_func(lib, config, opts, args): + show_stats(lib, make_query(args)) +stats_cmd.func = stats_func +default_subcommands.append(stats_cmd) \ No newline at end of file diff --git a/setup.py b/setup.py index eb15a1a11..5590198ec 100755 --- a/setup.py +++ b/setup.py @@ -49,7 +49,6 @@ setup(name='beets', 'mutagen', 'python-musicbrainz2 >= 0.7.0', 'munkres', - 'cmdln', 'eventlet >= 0.9.3', ], )