mirror of
https://github.com/rembo10/headphones.git
synced 2026-07-20 07:54:01 +01:00
A little cleanup of the libs due to OCD
This commit is contained in:
@@ -0,0 +1,980 @@
|
||||
# This file is part of beets.
|
||||
# Copyright 2014, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be
|
||||
# included in all copies or substantial portions of the Software.
|
||||
|
||||
"""This module contains all of the core logic for beets' command-line
|
||||
interface. To invoke the CLI, just call beets.ui.main(). The actual
|
||||
CLI commands are implemented in the ui.commands module.
|
||||
"""
|
||||
from __future__ import print_function
|
||||
|
||||
import locale
|
||||
import optparse
|
||||
import textwrap
|
||||
import sys
|
||||
from difflib import SequenceMatcher
|
||||
import logging
|
||||
import sqlite3
|
||||
import errno
|
||||
import re
|
||||
import struct
|
||||
import traceback
|
||||
|
||||
from beets import library
|
||||
from beets import plugins
|
||||
from beets import util
|
||||
from beets.util.functemplate import Template
|
||||
from beets import config
|
||||
from beets.util import confit
|
||||
from beets.autotag import mb
|
||||
|
||||
|
||||
# On Windows platforms, use colorama to support "ANSI" terminal colors.
|
||||
|
||||
if sys.platform == 'win32':
|
||||
try:
|
||||
import colorama
|
||||
except ImportError:
|
||||
pass
|
||||
else:
|
||||
colorama.init()
|
||||
|
||||
|
||||
|
||||
# Constants.
|
||||
|
||||
|
||||
PF_KEY_QUERIES = {
|
||||
'comp': 'comp:true',
|
||||
'singleton': 'singleton:true',
|
||||
}
|
||||
|
||||
# UI exception. Commands should throw this in order to display
|
||||
# nonrecoverable errors to the user.
|
||||
class UserError(Exception):
|
||||
pass
|
||||
|
||||
# Main logger.
|
||||
log = logging.getLogger('beets')
|
||||
|
||||
|
||||
|
||||
# Utilities.
|
||||
|
||||
|
||||
def _encoding():
|
||||
"""Tries to guess the encoding used by the terminal."""
|
||||
# Configured override?
|
||||
encoding = config['terminal_encoding'].get()
|
||||
if encoding:
|
||||
return encoding
|
||||
|
||||
# Determine from locale settings.
|
||||
try:
|
||||
return locale.getdefaultlocale()[1] or 'utf8'
|
||||
except ValueError:
|
||||
# Invalid locale environment variable setting. To avoid
|
||||
# failing entirely for no good reason, assume UTF-8.
|
||||
return 'utf8'
|
||||
|
||||
|
||||
def decargs(arglist):
|
||||
"""Given a list of command-line argument bytestrings, attempts to
|
||||
decode them to Unicode strings.
|
||||
"""
|
||||
return [s.decode(_encoding()) for s in arglist]
|
||||
|
||||
|
||||
def print_(*strings):
|
||||
"""Like print, but rather than raising an error when a character
|
||||
is not in the terminal's encoding's character set, just silently
|
||||
replaces it.
|
||||
"""
|
||||
if strings:
|
||||
if isinstance(strings[0], unicode):
|
||||
txt = u' '.join(strings)
|
||||
else:
|
||||
txt = ' '.join(strings)
|
||||
else:
|
||||
txt = u''
|
||||
if isinstance(txt, unicode):
|
||||
txt = txt.encode(_encoding(), 'replace')
|
||||
print(txt)
|
||||
|
||||
|
||||
def input_(prompt=None):
|
||||
"""Like `raw_input`, but decodes the result to a Unicode string.
|
||||
Raises a UserError if stdin is not available. The prompt is sent to
|
||||
stdout rather than stderr. A printed between the prompt and the
|
||||
input cursor.
|
||||
"""
|
||||
# raw_input incorrectly sends prompts to stderr, not stdout, so we
|
||||
# use print() explicitly to display prompts.
|
||||
# http://bugs.python.org/issue1927
|
||||
if prompt:
|
||||
if isinstance(prompt, unicode):
|
||||
prompt = prompt.encode(_encoding(), 'replace')
|
||||
print(prompt, end=' ')
|
||||
|
||||
try:
|
||||
resp = raw_input()
|
||||
except EOFError:
|
||||
raise UserError('stdin stream ended while input required')
|
||||
|
||||
return resp.decode(sys.stdin.encoding or 'utf8', 'ignore')
|
||||
|
||||
|
||||
def input_options(options, require=False, prompt=None, fallback_prompt=None,
|
||||
numrange=None, default=None, max_width=72):
|
||||
"""Prompts a user for input. The sequence of `options` defines the
|
||||
choices the user has. A single-letter shortcut is inferred for each
|
||||
option; the user's choice is returned as that single, lower-case
|
||||
letter. The options should be provided as lower-case strings unless
|
||||
a particular shortcut is desired; in that case, only that letter
|
||||
should be capitalized.
|
||||
|
||||
By default, the first option is the default. `default` can be provided to
|
||||
override this. If `require` is provided, then there is no default. The
|
||||
prompt and fallback prompt are also inferred but can be overridden.
|
||||
|
||||
If numrange is provided, it is a pair of `(high, low)` (both ints)
|
||||
indicating that, in addition to `options`, the user may enter an
|
||||
integer in that inclusive range.
|
||||
|
||||
`max_width` specifies the maximum number of columns in the
|
||||
automatically generated prompt string.
|
||||
"""
|
||||
# Assign single letters to each option. Also capitalize the options
|
||||
# to indicate the letter.
|
||||
letters = {}
|
||||
display_letters = []
|
||||
capitalized = []
|
||||
first = True
|
||||
for option in options:
|
||||
# Is a letter already capitalized?
|
||||
for letter in option:
|
||||
if letter.isalpha() and letter.upper() == letter:
|
||||
found_letter = letter
|
||||
break
|
||||
else:
|
||||
# Infer a letter.
|
||||
for letter in option:
|
||||
if not letter.isalpha():
|
||||
continue # Don't use punctuation.
|
||||
if letter not in letters:
|
||||
found_letter = letter
|
||||
break
|
||||
else:
|
||||
raise ValueError('no unambiguous lettering found')
|
||||
|
||||
letters[found_letter.lower()] = option
|
||||
index = option.index(found_letter)
|
||||
|
||||
# Mark the option's shortcut letter for display.
|
||||
if not require and ((default is None and not numrange and first) or
|
||||
(isinstance(default, basestring) and
|
||||
found_letter.lower() == default.lower())):
|
||||
# The first option is the default; mark it.
|
||||
show_letter = '[%s]' % found_letter.upper()
|
||||
is_default = True
|
||||
else:
|
||||
show_letter = found_letter.upper()
|
||||
is_default = False
|
||||
|
||||
# Colorize the letter shortcut.
|
||||
show_letter = colorize('turquoise' if is_default else 'blue',
|
||||
show_letter)
|
||||
|
||||
# Insert the highlighted letter back into the word.
|
||||
capitalized.append(
|
||||
option[:index] + show_letter + option[index + 1:]
|
||||
)
|
||||
display_letters.append(found_letter.upper())
|
||||
|
||||
first = False
|
||||
|
||||
# The default is just the first option if unspecified.
|
||||
if require:
|
||||
default = None
|
||||
elif default is None:
|
||||
if numrange:
|
||||
default = numrange[0]
|
||||
else:
|
||||
default = display_letters[0].lower()
|
||||
|
||||
# Make a prompt if one is not provided.
|
||||
if not prompt:
|
||||
prompt_parts = []
|
||||
prompt_part_lengths = []
|
||||
if numrange:
|
||||
if isinstance(default, int):
|
||||
default_name = str(default)
|
||||
default_name = colorize('turquoise', default_name)
|
||||
tmpl = '# selection (default %s)'
|
||||
prompt_parts.append(tmpl % default_name)
|
||||
prompt_part_lengths.append(len(tmpl % str(default)))
|
||||
else:
|
||||
prompt_parts.append('# selection')
|
||||
prompt_part_lengths.append(len(prompt_parts[-1]))
|
||||
prompt_parts += capitalized
|
||||
prompt_part_lengths += [len(s) for s in options]
|
||||
|
||||
# Wrap the query text.
|
||||
prompt = ''
|
||||
line_length = 0
|
||||
for i, (part, length) in enumerate(zip(prompt_parts,
|
||||
prompt_part_lengths)):
|
||||
# Add punctuation.
|
||||
if i == len(prompt_parts) - 1:
|
||||
part += '?'
|
||||
else:
|
||||
part += ','
|
||||
length += 1
|
||||
|
||||
# Choose either the current line or the beginning of the next.
|
||||
if line_length + length + 1 > max_width:
|
||||
prompt += '\n'
|
||||
line_length = 0
|
||||
|
||||
if line_length != 0:
|
||||
# Not the beginning of the line; need a space.
|
||||
part = ' ' + part
|
||||
length += 1
|
||||
|
||||
prompt += part
|
||||
line_length += length
|
||||
|
||||
# Make a fallback prompt too. This is displayed if the user enters
|
||||
# something that is not recognized.
|
||||
if not fallback_prompt:
|
||||
fallback_prompt = 'Enter one of '
|
||||
if numrange:
|
||||
fallback_prompt += '%i-%i, ' % numrange
|
||||
fallback_prompt += ', '.join(display_letters) + ':'
|
||||
|
||||
resp = input_(prompt)
|
||||
while True:
|
||||
resp = resp.strip().lower()
|
||||
|
||||
# Try default option.
|
||||
if default is not None and not resp:
|
||||
resp = default
|
||||
|
||||
# Try an integer input if available.
|
||||
if numrange:
|
||||
try:
|
||||
resp = int(resp)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
low, high = numrange
|
||||
if low <= resp <= high:
|
||||
return resp
|
||||
else:
|
||||
resp = None
|
||||
|
||||
# Try a normal letter input.
|
||||
if resp:
|
||||
resp = resp[0]
|
||||
if resp in letters:
|
||||
return resp
|
||||
|
||||
# Prompt for new input.
|
||||
resp = input_(fallback_prompt)
|
||||
|
||||
|
||||
def input_yn(prompt, require=False):
|
||||
"""Prompts the user for a "yes" or "no" response. The default is
|
||||
"yes" unless `require` is `True`, in which case there is no default.
|
||||
"""
|
||||
sel = input_options(
|
||||
('y', 'n'), require, prompt, 'Enter Y or N:'
|
||||
)
|
||||
return sel == 'y'
|
||||
|
||||
|
||||
def human_bytes(size):
|
||||
"""Formats size, a number of bytes, in a human-readable way."""
|
||||
suffices = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB', 'HB']
|
||||
for suffix in suffices:
|
||||
if size < 1024:
|
||||
return "%3.1f %s" % (size, suffix)
|
||||
size /= 1024.0
|
||||
return "big"
|
||||
|
||||
|
||||
def human_seconds(interval):
|
||||
"""Formats interval, a number of seconds, as a human-readable time
|
||||
interval using English words.
|
||||
"""
|
||||
units = [
|
||||
(1, 'second'),
|
||||
(60, 'minute'),
|
||||
(60, 'hour'),
|
||||
(24, 'day'),
|
||||
(7, 'week'),
|
||||
(52, 'year'),
|
||||
(10, 'decade'),
|
||||
]
|
||||
for i in range(len(units) - 1):
|
||||
increment, suffix = units[i]
|
||||
next_increment, _ = units[i + 1]
|
||||
interval /= float(increment)
|
||||
if interval < next_increment:
|
||||
break
|
||||
else:
|
||||
# Last unit.
|
||||
increment, suffix = units[-1]
|
||||
interval /= float(increment)
|
||||
|
||||
return "%3.1f %ss" % (interval, suffix)
|
||||
|
||||
|
||||
def human_seconds_short(interval):
|
||||
"""Formats a number of seconds as a short human-readable M:SS
|
||||
string.
|
||||
"""
|
||||
interval = int(interval)
|
||||
return u'%i:%02i' % (interval // 60, interval % 60)
|
||||
|
||||
|
||||
# ANSI terminal colorization code heavily inspired by pygments:
|
||||
# http://dev.pocoo.org/hg/pygments-main/file/b2deea5b5030/pygments/console.py
|
||||
# (pygments is by Tim Hatch, Armin Ronacher, et al.)
|
||||
COLOR_ESCAPE = "\x1b["
|
||||
DARK_COLORS = ["black", "darkred", "darkgreen", "brown", "darkblue",
|
||||
"purple", "teal", "lightgray"]
|
||||
LIGHT_COLORS = ["darkgray", "red", "green", "yellow", "blue",
|
||||
"fuchsia", "turquoise", "white"]
|
||||
RESET_COLOR = COLOR_ESCAPE + "39;49;00m"
|
||||
def _colorize(color, text):
|
||||
"""Returns a string that prints the given text in the given color
|
||||
in a terminal that is ANSI color-aware. The color must be something
|
||||
in DARK_COLORS or LIGHT_COLORS.
|
||||
"""
|
||||
if color in DARK_COLORS:
|
||||
escape = COLOR_ESCAPE + "%im" % (DARK_COLORS.index(color) + 30)
|
||||
elif color in LIGHT_COLORS:
|
||||
escape = COLOR_ESCAPE + "%i;01m" % (LIGHT_COLORS.index(color) + 30)
|
||||
else:
|
||||
raise ValueError('no such color %s', color)
|
||||
return escape + text + RESET_COLOR
|
||||
|
||||
|
||||
def colorize(color, text):
|
||||
"""Colorize text if colored output is enabled. (Like _colorize but
|
||||
conditional.)
|
||||
"""
|
||||
if config['color']:
|
||||
return _colorize(color, text)
|
||||
else:
|
||||
return text
|
||||
|
||||
|
||||
def _colordiff(a, b, highlight='red', minor_highlight='lightgray'):
|
||||
"""Given two values, return the same pair of strings except with
|
||||
their differences highlighted in the specified color. Strings are
|
||||
highlighted intelligently to show differences; other values are
|
||||
stringified and highlighted in their entirety.
|
||||
"""
|
||||
if not isinstance(a, basestring) or not isinstance(b, basestring):
|
||||
# Non-strings: use ordinary equality.
|
||||
a = unicode(a)
|
||||
b = unicode(b)
|
||||
if a == b:
|
||||
return a, b
|
||||
else:
|
||||
return colorize(highlight, a), colorize(highlight, b)
|
||||
|
||||
if isinstance(a, bytes) or isinstance(b, bytes):
|
||||
# A path field.
|
||||
a = util.displayable_path(a)
|
||||
b = util.displayable_path(b)
|
||||
|
||||
a_out = []
|
||||
b_out = []
|
||||
|
||||
matcher = SequenceMatcher(lambda x: False, a, b)
|
||||
for op, a_start, a_end, b_start, b_end in matcher.get_opcodes():
|
||||
if op == 'equal':
|
||||
# In both strings.
|
||||
a_out.append(a[a_start:a_end])
|
||||
b_out.append(b[b_start:b_end])
|
||||
elif op == 'insert':
|
||||
# Right only.
|
||||
b_out.append(colorize(highlight, b[b_start:b_end]))
|
||||
elif op == 'delete':
|
||||
# Left only.
|
||||
a_out.append(colorize(highlight, a[a_start:a_end]))
|
||||
elif op == 'replace':
|
||||
# Right and left differ. Colorise with second highlight if
|
||||
# it's just a case change.
|
||||
if a[a_start:a_end].lower() != b[b_start:b_end].lower():
|
||||
color = highlight
|
||||
else:
|
||||
color = minor_highlight
|
||||
a_out.append(colorize(color, a[a_start:a_end]))
|
||||
b_out.append(colorize(color, b[b_start:b_end]))
|
||||
else:
|
||||
assert(False)
|
||||
|
||||
return u''.join(a_out), u''.join(b_out)
|
||||
|
||||
|
||||
def colordiff(a, b, highlight='red'):
|
||||
"""Colorize differences between two values if color is enabled.
|
||||
(Like _colordiff but conditional.)
|
||||
"""
|
||||
if config['color']:
|
||||
return _colordiff(a, b, highlight)
|
||||
else:
|
||||
return unicode(a), unicode(b)
|
||||
|
||||
|
||||
def color_diff_suffix(a, b, highlight='red'):
|
||||
"""Colorize the differing suffix between two strings."""
|
||||
a, b = unicode(a), unicode(b)
|
||||
if not config['color']:
|
||||
return a, b
|
||||
|
||||
# Fast path.
|
||||
if a == b:
|
||||
return a, b
|
||||
|
||||
# Find the longest common prefix.
|
||||
first_diff = None
|
||||
for i in range(min(len(a), len(b))):
|
||||
if a[i] != b[i]:
|
||||
first_diff = i
|
||||
break
|
||||
else:
|
||||
first_diff = min(len(a), len(b))
|
||||
|
||||
# Colorize from the first difference on.
|
||||
return a[:first_diff] + colorize(highlight, a[first_diff:]), \
|
||||
b[:first_diff] + colorize(highlight, b[first_diff:])
|
||||
|
||||
|
||||
def get_path_formats(subview=None):
|
||||
"""Get the configuration's path formats as a list of query/template
|
||||
pairs.
|
||||
"""
|
||||
path_formats = []
|
||||
subview = subview or config['paths']
|
||||
for query, view in subview.items():
|
||||
query = PF_KEY_QUERIES.get(query, query) # Expand common queries.
|
||||
path_formats.append((query, Template(view.get(unicode))))
|
||||
return path_formats
|
||||
|
||||
|
||||
def get_replacements():
|
||||
"""Confit validation function that reads regex/string pairs.
|
||||
"""
|
||||
replacements = []
|
||||
for pattern, repl in config['replace'].get(dict).items():
|
||||
repl = repl or ''
|
||||
try:
|
||||
replacements.append((re.compile(pattern), repl))
|
||||
except re.error:
|
||||
raise UserError(
|
||||
u'malformed regular expression in replace: {0}'.format(
|
||||
pattern
|
||||
)
|
||||
)
|
||||
return replacements
|
||||
|
||||
|
||||
def get_plugin_paths():
|
||||
"""Get the list of search paths for plugins from the config file.
|
||||
The value for "pluginpath" may be a single string or a list of
|
||||
strings.
|
||||
"""
|
||||
pluginpaths = config['pluginpath'].get()
|
||||
if isinstance(pluginpaths, basestring):
|
||||
pluginpaths = [pluginpaths]
|
||||
if not isinstance(pluginpaths, list):
|
||||
raise confit.ConfigTypeError(
|
||||
u'pluginpath must be string or a list of strings'
|
||||
)
|
||||
return map(util.normpath, pluginpaths)
|
||||
|
||||
|
||||
def _pick_format(album, fmt=None):
|
||||
"""Pick a format string for printing Album or Item objects,
|
||||
falling back to config options and defaults.
|
||||
"""
|
||||
if fmt:
|
||||
return fmt
|
||||
if album:
|
||||
return config['list_format_album'].get(unicode)
|
||||
else:
|
||||
return config['list_format_item'].get(unicode)
|
||||
|
||||
|
||||
def print_obj(obj, lib, fmt=None):
|
||||
"""Print an Album or Item object. If `fmt` is specified, use that
|
||||
format string. Otherwise, use the configured template.
|
||||
"""
|
||||
album = isinstance(obj, library.Album)
|
||||
fmt = _pick_format(album, fmt)
|
||||
if isinstance(fmt, Template):
|
||||
template = fmt
|
||||
else:
|
||||
template = Template(fmt)
|
||||
print_(obj.evaluate_template(template))
|
||||
|
||||
|
||||
def term_width():
|
||||
"""Get the width (columns) of the terminal."""
|
||||
fallback = config['ui']['terminal_width'].get(int)
|
||||
|
||||
# The fcntl and termios modules are not available on non-Unix
|
||||
# platforms, so we fall back to a constant.
|
||||
try:
|
||||
import fcntl
|
||||
import termios
|
||||
except ImportError:
|
||||
return fallback
|
||||
|
||||
try:
|
||||
buf = fcntl.ioctl(0, termios.TIOCGWINSZ, ' ' * 4)
|
||||
except IOError:
|
||||
return fallback
|
||||
try:
|
||||
height, width = struct.unpack('hh', buf)
|
||||
except struct.error:
|
||||
return fallback
|
||||
return width
|
||||
|
||||
|
||||
FLOAT_EPSILON = 0.01
|
||||
def _field_diff(field, old, new):
|
||||
"""Given two Model objects, format their values for `field` and
|
||||
highlight changes among them. Return a human-readable string. If the
|
||||
value has not changed, return None instead.
|
||||
"""
|
||||
oldval = old.get(field)
|
||||
newval = new.get(field)
|
||||
|
||||
# If no change, abort.
|
||||
if isinstance(oldval, float) and isinstance(newval, float) and \
|
||||
abs(oldval - newval) < FLOAT_EPSILON:
|
||||
return None
|
||||
elif oldval == newval:
|
||||
return None
|
||||
|
||||
# Get formatted values for output.
|
||||
oldstr = old._get_formatted(field)
|
||||
newstr = new._get_formatted(field)
|
||||
|
||||
# For strings, highlight changes. For others, colorize the whole
|
||||
# thing.
|
||||
if isinstance(oldval, basestring):
|
||||
oldstr, newstr = colordiff(oldval, newval)
|
||||
else:
|
||||
oldstr, newstr = colorize('red', oldstr), colorize('red', newstr)
|
||||
|
||||
return u'{0} -> {1}'.format(oldstr, newstr)
|
||||
|
||||
|
||||
def show_model_changes(new, old=None, fields=None, always=False):
|
||||
"""Given a Model object, print a list of changes from its pristine
|
||||
version stored in the database. Return a boolean indicating whether
|
||||
any changes were found.
|
||||
|
||||
`old` may be the "original" object to avoid using the pristine
|
||||
version from the database. `fields` may be a list of fields to
|
||||
restrict the detection to. `always` indicates whether the object is
|
||||
always identified, regardless of whether any changes are present.
|
||||
"""
|
||||
old = old or new._db._get(type(new), new.id)
|
||||
|
||||
# Build up lines showing changed fields.
|
||||
changes = []
|
||||
for field in old:
|
||||
# Subset of the fields. Never show mtime.
|
||||
if field == 'mtime' or (fields and field not in fields):
|
||||
continue
|
||||
|
||||
# Detect and show difference for this field.
|
||||
line = _field_diff(field, old, new)
|
||||
if line:
|
||||
changes.append(u' {0}: {1}'.format(field, line))
|
||||
|
||||
# New fields.
|
||||
for field in set(new) - set(old):
|
||||
changes.append(u' {0}: {1}'.format(
|
||||
field,
|
||||
colorize('red', new._get_formatted(field))
|
||||
))
|
||||
|
||||
# Print changes.
|
||||
if changes or always:
|
||||
print_obj(old, old._db)
|
||||
if changes:
|
||||
print_(u'\n'.join(changes))
|
||||
|
||||
return bool(changes)
|
||||
|
||||
|
||||
|
||||
# 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=None, help='', aliases=(), hide=False):
|
||||
"""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. If no parser is
|
||||
given, it defaults to a new, empty OptionParser.
|
||||
"""
|
||||
self.name = name
|
||||
self.parser = parser or optparse.OptionParser()
|
||||
self.aliases = aliases
|
||||
self.help = help
|
||||
self.hide = hide
|
||||
|
||||
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()
|
||||
|
||||
def add_subcommand(self, cmd):
|
||||
"""Adds a Subcommand object to the parser's list of commands.
|
||||
"""
|
||||
self.subcommands.append(cmd)
|
||||
|
||||
# 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
|
||||
subcommands = [c for c in self.subcommands if not c.hide]
|
||||
for subcommand in 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(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)
|
||||
subcommand, suboptions, subargs = self._parse_sub(args)
|
||||
return options, subcommand, suboptions, subargs
|
||||
|
||||
def _parse_sub(self, args):
|
||||
"""Given the `args` left unused by a typical OptionParser
|
||||
`parse_args`, return the invoked subcommand, the subcommand
|
||||
options, and the subcommand arguments.
|
||||
"""
|
||||
if not args:
|
||||
# No command given.
|
||||
self.print_help()
|
||||
self.exit()
|
||||
else:
|
||||
cmdname = args.pop(0)
|
||||
subcommand = self._subcommand_for_name(cmdname)
|
||||
if not subcommand:
|
||||
self.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)
|
||||
if not helpcommand:
|
||||
self.error('no command named {0}'.format(cmdname))
|
||||
helpcommand.parser.print_help()
|
||||
self.exit()
|
||||
else:
|
||||
# general
|
||||
self.print_help()
|
||||
self.exit()
|
||||
|
||||
return subcommand, suboptions, subargs
|
||||
|
||||
|
||||
optparse.Option.ALWAYS_TYPED_ACTIONS += ('callback',)
|
||||
def vararg_callback(option, opt_str, value, parser):
|
||||
"""Callback for an option with variable arguments.
|
||||
Manually collect arguments right of a callback-action
|
||||
option (ie. with action="callback"), and add the resulting
|
||||
list to the destination var.
|
||||
|
||||
Usage:
|
||||
parser.add_option("-c", "--callback", dest="vararg_attr",
|
||||
action="callback", callback=vararg_callback)
|
||||
|
||||
Details:
|
||||
http://docs.python.org/2/library/optparse.html#callback-example-6-variable
|
||||
-arguments
|
||||
"""
|
||||
value = [value]
|
||||
|
||||
def floatable(str):
|
||||
try:
|
||||
float(str)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
for arg in parser.rargs:
|
||||
# stop on --foo like options
|
||||
if arg[:2] == "--" and len(arg) > 2:
|
||||
break
|
||||
# stop on -a, but not on -3 or -3.0
|
||||
if arg[:1] == "-" and len(arg) > 1 and not floatable(arg):
|
||||
break
|
||||
value.append(arg)
|
||||
|
||||
del parser.rargs[:len(value) - 1]
|
||||
setattr(parser.values, option.dest, value)
|
||||
|
||||
|
||||
|
||||
# The main entry point and bootstrapping.
|
||||
|
||||
|
||||
def _load_plugins():
|
||||
"""Load the plugins specified in the configuration.
|
||||
"""
|
||||
# Add plugin paths.
|
||||
import beetsplug
|
||||
beetsplug.__path__ = get_plugin_paths() + beetsplug.__path__
|
||||
|
||||
# For backwards compatibility.
|
||||
sys.path += get_plugin_paths()
|
||||
|
||||
# Load requested plugins.
|
||||
plugins.load_plugins(config['plugins'].as_str_seq())
|
||||
plugins.send("pluginload")
|
||||
|
||||
|
||||
def _configure(args):
|
||||
"""Parse the command line, load configuration files (including
|
||||
loading any indicated plugins), and return the invoked subcomand,
|
||||
the subcommand options, and the subcommand arguments.
|
||||
"""
|
||||
# Temporary: Migrate from 1.0-style configuration.
|
||||
from beets.ui import migrate
|
||||
migrate.automigrate()
|
||||
|
||||
# Get the default subcommands.
|
||||
from beets.ui.commands import default_commands
|
||||
|
||||
# Construct the root parser.
|
||||
commands = list(default_commands)
|
||||
commands.append(migrate.migrate_cmd) # Temporary.
|
||||
parser = SubcommandsOptionParser(subcommands=commands)
|
||||
parser.add_option('-l', '--library', dest='library',
|
||||
help='library database file to use')
|
||||
parser.add_option('-d', '--directory', dest='directory',
|
||||
help="destination music directory")
|
||||
parser.add_option('-v', '--verbose', dest='verbose', action='store_true',
|
||||
help='print debugging information')
|
||||
parser.add_option('-c', '--config', dest='config',
|
||||
help='path to configuration file')
|
||||
|
||||
# Parse the command-line!
|
||||
options, args = optparse.OptionParser.parse_args(parser, args)
|
||||
|
||||
# Add any additional config files specified with --config. This
|
||||
# special handling lets specified plugins get loaded before we
|
||||
# finish parsing the command line.
|
||||
if getattr(options, 'config', None) is not None:
|
||||
config_path = options.config
|
||||
del options.config
|
||||
config.set_file(config_path)
|
||||
config.set_args(options)
|
||||
|
||||
# Now add the plugin commands to the parser.
|
||||
_load_plugins()
|
||||
for cmd in plugins.commands():
|
||||
parser.add_subcommand(cmd)
|
||||
|
||||
# Parse the remainder of the command line with loaded plugins.
|
||||
return parser._parse_sub(args)
|
||||
|
||||
|
||||
def _raw_main(args):
|
||||
"""A helper function for `main` without top-level exception
|
||||
handling.
|
||||
"""
|
||||
subcommand, suboptions, subargs = _configure(args)
|
||||
|
||||
# Open library file.
|
||||
dbpath = config['library'].as_filename()
|
||||
try:
|
||||
lib = library.Library(
|
||||
dbpath,
|
||||
config['directory'].as_filename(),
|
||||
get_path_formats(),
|
||||
get_replacements(),
|
||||
)
|
||||
except sqlite3.OperationalError:
|
||||
raise UserError(u"database file {0} could not be opened".format(
|
||||
util.displayable_path(dbpath)
|
||||
))
|
||||
plugins.send("library_opened", lib=lib)
|
||||
|
||||
# Configure the logger.
|
||||
if config['verbose'].get(bool):
|
||||
log.setLevel(logging.DEBUG)
|
||||
else:
|
||||
log.setLevel(logging.INFO)
|
||||
log.debug(u'data directory: {0}\n'
|
||||
u'library database: {1}\n'
|
||||
u'library directory: {2}'
|
||||
.format(
|
||||
util.displayable_path(config.config_dir()),
|
||||
util.displayable_path(lib.path),
|
||||
util.displayable_path(lib.directory),
|
||||
)
|
||||
)
|
||||
|
||||
# Configure the MusicBrainz API.
|
||||
mb.configure()
|
||||
|
||||
# Invoke the subcommand.
|
||||
subcommand.func(lib, suboptions, subargs)
|
||||
plugins.send('cli_exit', lib=lib)
|
||||
|
||||
|
||||
def main(args=None):
|
||||
"""Run the main command-line interface for beets. Includes top-level
|
||||
exception handlers that print friendly error messages.
|
||||
"""
|
||||
try:
|
||||
_raw_main(args)
|
||||
except UserError as exc:
|
||||
message = exc.args[0] if exc.args else None
|
||||
log.error(u'error: {0}'.format(message))
|
||||
sys.exit(1)
|
||||
except util.HumanReadableException as exc:
|
||||
exc.log(log)
|
||||
sys.exit(1)
|
||||
except library.FileOperationError as exc:
|
||||
# These errors have reasonable human-readable descriptions, but
|
||||
# we still want to log their tracebacks for debugging.
|
||||
log.debug(traceback.format_exc())
|
||||
log.error(exc)
|
||||
sys.exit(1)
|
||||
except confit.ConfigError as exc:
|
||||
log.error(u'configuration error: {0}'.format(exc))
|
||||
sys.exit(1)
|
||||
except IOError as exc:
|
||||
if exc.errno == errno.EPIPE:
|
||||
# "Broken pipe". End silently.
|
||||
pass
|
||||
else:
|
||||
raise
|
||||
except KeyboardInterrupt:
|
||||
# Silently ignore ^C except in verbose mode.
|
||||
log.debug(traceback.format_exc())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,162 @@
|
||||
# This file is part of beets.
|
||||
# Copyright (c) 2014, Thomas Scholtes.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be
|
||||
# included in all copies or substantial portions of the Software.
|
||||
|
||||
|
||||
|
||||
# Completion for the `beet` command
|
||||
# =================================
|
||||
#
|
||||
# Load this script to complete beets subcommands, options, and
|
||||
# queries.
|
||||
#
|
||||
# If a beets command is found on the command line it completes filenames and
|
||||
# the subcommand's options. Otherwise it will complete global options and
|
||||
# subcommands. If the previous option on the command line expects an argument,
|
||||
# it also completes filenames or directories. Options are only
|
||||
# completed if '-' has already been typed on the command line.
|
||||
#
|
||||
# Note that completion of plugin commands only works for those plugins
|
||||
# that were enabled when running `beet completion`. It does not check
|
||||
# plugins dynamically
|
||||
#
|
||||
# Currently, only Bash 3.2 and newer is supported and the
|
||||
# `bash-completion` package is requied.
|
||||
#
|
||||
# TODO
|
||||
# ----
|
||||
#
|
||||
# * There are some issues with arguments that are quoted on the command line.
|
||||
#
|
||||
# * Complete arguments for the `--format` option by expanding field variables.
|
||||
#
|
||||
# beet ls -f "$tit[TAB]
|
||||
# beet ls -f "$title
|
||||
#
|
||||
# * Support long options with `=`, e.g. `--config=file`. Debian's bash
|
||||
# completion package can handle this.
|
||||
#
|
||||
|
||||
|
||||
# Determines the beets subcommand and dispatches the completion
|
||||
# accordingly.
|
||||
_beet_dispatch() {
|
||||
local cur prev cmd=
|
||||
|
||||
COMPREPLY=()
|
||||
_get_comp_words_by_ref -n : cur prev
|
||||
|
||||
# Look for the beets subcommand
|
||||
local arg
|
||||
for (( i=1; i < COMP_CWORD; i++ )); do
|
||||
arg="${COMP_WORDS[i]}"
|
||||
if _list_include_item "${opts___global}" $arg; then
|
||||
((i++))
|
||||
elif [[ "$arg" != -* ]]; then
|
||||
cmd="$arg"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# Replace command shortcuts
|
||||
if [[ -n $cmd ]] && _list_include_item "$aliases" "$cmd"; then
|
||||
eval "cmd=\$alias__$cmd"
|
||||
fi
|
||||
|
||||
case $cmd in
|
||||
help)
|
||||
COMPREPLY+=( $(compgen -W "$commands" -- $cur) )
|
||||
;;
|
||||
list|remove|move|update|write|stats)
|
||||
_beet_complete_query
|
||||
;;
|
||||
"")
|
||||
_beet_complete_global
|
||||
;;
|
||||
*)
|
||||
_beet_complete
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
|
||||
# Adds option and file completion to COMPREPLY for the subcommand $cmd
|
||||
_beet_complete() {
|
||||
if [[ $cur == -* ]]; then
|
||||
local opts flags completions
|
||||
eval "opts=\$opts__$cmd"
|
||||
eval "flags=\$flags__$cmd"
|
||||
completions="${flags___common} ${opts} ${flags}"
|
||||
COMPREPLY+=( $(compgen -W "$completions" -- $cur) )
|
||||
else
|
||||
_filedir
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
# Add global options and subcommands to the completion
|
||||
_beet_complete_global() {
|
||||
case $prev in
|
||||
-h|--help)
|
||||
# Complete commands
|
||||
COMPREPLY+=( $(compgen -W "$commands" -- $cur) )
|
||||
return
|
||||
;;
|
||||
-l|--library|-c|--config)
|
||||
# Filename completion
|
||||
_filedir
|
||||
return
|
||||
;;
|
||||
-d|--directory)
|
||||
# Directory completion
|
||||
_filedir -d
|
||||
return
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ $cur == -* ]]; then
|
||||
local completions="$opts___global $flags___global"
|
||||
COMPREPLY+=( $(compgen -W "$completions" -- $cur) )
|
||||
elif [[ -n $cur ]] && _list_include_item "$aliases" "$cur"; then
|
||||
local cmd
|
||||
eval "cmd=\$alias__$cur"
|
||||
COMPREPLY+=( "$cmd" )
|
||||
else
|
||||
COMPREPLY+=( $(compgen -W "$commands" -- $cur) )
|
||||
fi
|
||||
}
|
||||
|
||||
_beet_complete_query() {
|
||||
local opts
|
||||
eval "opts=\$opts__$cmd"
|
||||
|
||||
if [[ $cur == -* ]] || _list_include_item "$opts" "$prev"; then
|
||||
_beet_complete
|
||||
elif [[ $cur != \'* && $cur != \"* &&
|
||||
$cur != *:* ]]; then
|
||||
# Do not complete quoted queries or those who already have a field
|
||||
# set.
|
||||
compopt -o nospace
|
||||
COMPREPLY+=( $(compgen -S : -W "$fields" -- $cur) )
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
# Returns true if the space separated list $1 includes $2
|
||||
_list_include_item() {
|
||||
[[ " $1 " == *[[:space:]]$2[[:space:]]* ]]
|
||||
}
|
||||
|
||||
# This is where beets dynamically adds the _beet function. This
|
||||
# function sets the variables $flags, $opts, $commands, and $aliases.
|
||||
complete -o filenames -F _beet beet
|
||||
@@ -0,0 +1,401 @@
|
||||
# This file is part of beets.
|
||||
# Copyright 2013, Adrian Sampson.
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining
|
||||
# a copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be
|
||||
# included in all copies or substantial portions of the Software.
|
||||
|
||||
"""Conversion from legacy (pre-1.1) configuration to Confit/YAML
|
||||
configuration.
|
||||
"""
|
||||
import os
|
||||
import ConfigParser
|
||||
import codecs
|
||||
import yaml
|
||||
import logging
|
||||
import time
|
||||
import itertools
|
||||
import re
|
||||
|
||||
import beets
|
||||
from beets import util
|
||||
from beets import ui
|
||||
from beets.util import confit
|
||||
|
||||
CONFIG_PATH_VAR = 'BEETSCONFIG'
|
||||
DEFAULT_CONFIG_FILENAME_UNIX = '.beetsconfig'
|
||||
DEFAULT_CONFIG_FILENAME_WINDOWS = 'beetsconfig.ini'
|
||||
DEFAULT_LIBRARY_FILENAME_UNIX = '.beetsmusic.blb'
|
||||
DEFAULT_LIBRARY_FILENAME_WINDOWS = 'beetsmusic.blb'
|
||||
WINDOWS_BASEDIR = os.environ.get('APPDATA') or '~'
|
||||
|
||||
OLD_CONFIG_SUFFIX = '.old'
|
||||
PLUGIN_NAMES = {
|
||||
'rdm': 'random',
|
||||
'fuzzy_search': 'fuzzy',
|
||||
}
|
||||
AUTO_KEYS = ('automatic', 'autofetch', 'autoembed', 'autoscrub')
|
||||
IMPORTFEEDS_PREFIX = 'feeds_'
|
||||
CONFIG_MIGRATED_MESSAGE = u"""
|
||||
You appear to be upgrading from beets 1.0 (or earlier) to 1.1. Your
|
||||
configuration file has been migrated automatically to:
|
||||
{newconfig}
|
||||
Edit this file to configure beets. You might want to remove your
|
||||
old-style ".beetsconfig" file now. See the documentation for more
|
||||
details on the new configuration system:
|
||||
http://beets.readthedocs.org/page/reference/config.html
|
||||
""".strip()
|
||||
DB_MIGRATED_MESSAGE = u'Your database file has also been copied to:\n{newdb}'
|
||||
YAML_COMMENT = '# Automatically migrated from legacy .beetsconfig.\n\n'
|
||||
|
||||
log = logging.getLogger('beets')
|
||||
|
||||
# An itertools recipe.
|
||||
def grouper(n, iterable):
|
||||
args = [iter(iterable)] * n
|
||||
return itertools.izip_longest(*args)
|
||||
|
||||
def _displace(fn):
|
||||
"""Move a file aside using a timestamp suffix so a new file can be
|
||||
put in its place.
|
||||
"""
|
||||
util.move(
|
||||
fn,
|
||||
u'{0}.old.{1}'.format(fn, int(time.time())),
|
||||
True
|
||||
)
|
||||
|
||||
def default_paths():
|
||||
"""Produces the appropriate default config and library database
|
||||
paths for the current system. On Unix, this is always in ~. On
|
||||
Windows, tries ~ first and then $APPDATA for the config and library
|
||||
files (for backwards compatibility).
|
||||
"""
|
||||
windows = os.path.__name__ == 'ntpath'
|
||||
if windows:
|
||||
windata = os.environ.get('APPDATA') or '~'
|
||||
|
||||
# Shorthand for joining paths.
|
||||
def exp(*vals):
|
||||
return os.path.expanduser(os.path.join(*vals))
|
||||
|
||||
config = exp('~', DEFAULT_CONFIG_FILENAME_UNIX)
|
||||
if windows and not os.path.exists(config):
|
||||
config = exp(windata, DEFAULT_CONFIG_FILENAME_WINDOWS)
|
||||
|
||||
libpath = exp('~', DEFAULT_LIBRARY_FILENAME_UNIX)
|
||||
if windows and not os.path.exists(libpath):
|
||||
libpath = exp(windata, DEFAULT_LIBRARY_FILENAME_WINDOWS)
|
||||
|
||||
return config, libpath
|
||||
|
||||
def get_config():
|
||||
"""Using the same logic as beets 1.0, locate and read the
|
||||
.beetsconfig file. Return a ConfigParser instance or None if no
|
||||
config is found.
|
||||
"""
|
||||
default_config, default_libpath = default_paths()
|
||||
if CONFIG_PATH_VAR in os.environ:
|
||||
configpath = os.path.expanduser(os.environ[CONFIG_PATH_VAR])
|
||||
else:
|
||||
configpath = default_config
|
||||
|
||||
config = ConfigParser.SafeConfigParser()
|
||||
if os.path.exists(util.syspath(configpath)):
|
||||
with codecs.open(configpath, 'r', encoding='utf-8') as f:
|
||||
config.readfp(f)
|
||||
return config, configpath
|
||||
else:
|
||||
return None, configpath
|
||||
|
||||
def flatten_config(config):
|
||||
"""Given a ConfigParser, flatten the values into a dict-of-dicts
|
||||
representation where each section gets its own dictionary of values.
|
||||
"""
|
||||
out = confit.OrderedDict()
|
||||
for section in config.sections():
|
||||
sec_dict = out[section] = confit.OrderedDict()
|
||||
for option in config.options(section):
|
||||
sec_dict[option] = config.get(section, option, True)
|
||||
return out
|
||||
|
||||
def transform_value(value):
|
||||
"""Given a string read as the value of a config option, return a
|
||||
massaged version of that value (possibly with a different type).
|
||||
"""
|
||||
# Booleans.
|
||||
if value.lower() in ('false', 'no', 'off'):
|
||||
return False
|
||||
elif value.lower() in ('true', 'yes', 'on'):
|
||||
return True
|
||||
|
||||
# Integers.
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Floats.
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return value
|
||||
|
||||
def transform_data(data):
|
||||
"""Given a dict-of-dicts representation of legacy config data, tweak
|
||||
the data into a new form. This new form is suitable for dumping as
|
||||
YAML.
|
||||
"""
|
||||
out = confit.OrderedDict()
|
||||
|
||||
for section, pairs in data.items():
|
||||
if section == 'beets':
|
||||
# The "main" section. In the new config system, these values
|
||||
# are in the "root": no section at all.
|
||||
for key, value in pairs.items():
|
||||
value = transform_value(value)
|
||||
|
||||
if key.startswith('import_'):
|
||||
# Importer config is now under an "import:" key.
|
||||
if 'import' not in out:
|
||||
out['import'] = confit.OrderedDict()
|
||||
out['import'][key[7:]] = value
|
||||
|
||||
elif key == 'plugins':
|
||||
# Renamed plugins.
|
||||
plugins = value.split()
|
||||
new_plugins = [PLUGIN_NAMES.get(p, p) for p in plugins]
|
||||
out['plugins'] = ' '.join(new_plugins)
|
||||
|
||||
elif key == 'replace':
|
||||
# YAMLy representation for character replacements.
|
||||
replacements = confit.OrderedDict()
|
||||
for pat, repl in grouper(2, value.split()):
|
||||
if repl == '<strip>':
|
||||
repl = ''
|
||||
replacements[pat] = repl
|
||||
out['replace'] = replacements
|
||||
|
||||
elif key == 'pluginpath':
|
||||
# Used to be a colon-separated string. Now a list.
|
||||
out['pluginpath'] = value.split(':')
|
||||
|
||||
else:
|
||||
out[key] = value
|
||||
|
||||
elif pairs:
|
||||
# Other sections (plugins, etc).
|
||||
sec_out = out[section] = confit.OrderedDict()
|
||||
for key, value in pairs.items():
|
||||
|
||||
# Standardized "auto" option.
|
||||
if key in AUTO_KEYS:
|
||||
key = 'auto'
|
||||
|
||||
# Unnecessary : hack in queries.
|
||||
if section == 'paths':
|
||||
key = key.replace('_', ':')
|
||||
|
||||
# Changed option names for importfeeds plugin.
|
||||
if section == 'importfeeds':
|
||||
if key.startswith(IMPORTFEEDS_PREFIX):
|
||||
key = key[len(IMPORTFEEDS_PREFIX):]
|
||||
|
||||
sec_out[key] = transform_value(value)
|
||||
|
||||
return out
|
||||
|
||||
class Dumper(yaml.SafeDumper):
|
||||
"""A PyYAML Dumper that represents OrderedDicts as ordinary mappings
|
||||
(in order, of course).
|
||||
"""
|
||||
# From http://pyyaml.org/attachment/ticket/161/use_ordered_dict.py
|
||||
def represent_mapping(self, tag, mapping, flow_style=None):
|
||||
value = []
|
||||
node = yaml.MappingNode(tag, value, flow_style=flow_style)
|
||||
if self.alias_key is not None:
|
||||
self.represented_objects[self.alias_key] = node
|
||||
best_style = True
|
||||
if hasattr(mapping, 'items'):
|
||||
mapping = list(mapping.items())
|
||||
for item_key, item_value in mapping:
|
||||
node_key = self.represent_data(item_key)
|
||||
node_value = self.represent_data(item_value)
|
||||
if not (isinstance(node_key, yaml.ScalarNode) and \
|
||||
not node_key.style):
|
||||
best_style = False
|
||||
if not (isinstance(node_value, yaml.ScalarNode) and \
|
||||
not node_value.style):
|
||||
best_style = False
|
||||
value.append((node_key, node_value))
|
||||
if flow_style is None:
|
||||
if self.default_flow_style is not None:
|
||||
node.flow_style = self.default_flow_style
|
||||
else:
|
||||
node.flow_style = best_style
|
||||
return node
|
||||
Dumper.add_representer(confit.OrderedDict, Dumper.represent_dict)
|
||||
|
||||
def migrate_config(replace=False):
|
||||
"""Migrate a legacy beetsconfig file to a new-style config.yaml file
|
||||
in an appropriate place. If `replace` is enabled, then any existing
|
||||
config.yaml will be moved aside. Otherwise, the process is aborted
|
||||
when the file exists.
|
||||
"""
|
||||
|
||||
# Load legacy configuration data, if any.
|
||||
config, configpath = get_config()
|
||||
if not config:
|
||||
log.debug(u'no config file found at {0}'.format(
|
||||
util.displayable_path(configpath)
|
||||
))
|
||||
return
|
||||
|
||||
# Get the new configuration file path and possibly move it out of
|
||||
# the way.
|
||||
destfn = os.path.join(beets.config.config_dir(), confit.CONFIG_FILENAME)
|
||||
if os.path.exists(destfn):
|
||||
if replace:
|
||||
log.debug(u'moving old config aside: {0}'.format(
|
||||
util.displayable_path(destfn)
|
||||
))
|
||||
_displace(destfn)
|
||||
else:
|
||||
# File exists and we won't replace it. We're done.
|
||||
return
|
||||
|
||||
log.debug(u'migrating config file {0}'.format(
|
||||
util.displayable_path(configpath)
|
||||
))
|
||||
|
||||
# Convert the configuration to a data structure ready to be dumped
|
||||
# as the new Confit file.
|
||||
data = transform_data(flatten_config(config))
|
||||
|
||||
# Encode result as YAML.
|
||||
yaml_out = yaml.dump(
|
||||
data,
|
||||
Dumper=Dumper,
|
||||
default_flow_style=False,
|
||||
indent=4,
|
||||
width=1000,
|
||||
)
|
||||
# A ridiculous little hack to add some whitespace between "sections"
|
||||
# in the YAML output. I hope this doesn't break any YAML syntax.
|
||||
yaml_out = re.sub(r'(\n\w+:\n [^-\s])', '\n\\1', yaml_out)
|
||||
yaml_out = YAML_COMMENT + yaml_out
|
||||
|
||||
# Write the data to the new config destination.
|
||||
log.debug(u'writing migrated config to {0}'.format(
|
||||
util.displayable_path(destfn)
|
||||
))
|
||||
with open(destfn, 'w') as f:
|
||||
f.write(yaml_out)
|
||||
return destfn
|
||||
|
||||
def migrate_db(replace=False):
|
||||
"""Copy the beets library database file to the new location (e.g.,
|
||||
from ~/.beetsmusic.blb to ~/.config/beets/library.db).
|
||||
"""
|
||||
_, srcfn = default_paths()
|
||||
destfn = beets.config['library'].as_filename()
|
||||
|
||||
if not os.path.exists(srcfn) or srcfn == destfn:
|
||||
# Old DB does not exist or we're configured to point to the same
|
||||
# database. Do nothing.
|
||||
return
|
||||
|
||||
if os.path.exists(destfn):
|
||||
if replace:
|
||||
log.debug(u'moving old database aside: {0}'.format(
|
||||
util.displayable_path(destfn)
|
||||
))
|
||||
_displace(destfn)
|
||||
else:
|
||||
return
|
||||
|
||||
log.debug(u'copying database from {0} to {1}'.format(
|
||||
util.displayable_path(srcfn), util.displayable_path(destfn)
|
||||
))
|
||||
util.copy(srcfn, destfn)
|
||||
return destfn
|
||||
|
||||
def migrate_state(replace=False):
|
||||
"""Copy the beets runtime state file from the old path (i.e.,
|
||||
~/.beetsstate) to the new path (i.e., ~/.config/beets/state.pickle).
|
||||
"""
|
||||
srcfn = os.path.expanduser(os.path.join('~', '.beetsstate'))
|
||||
if not os.path.exists(srcfn):
|
||||
return
|
||||
|
||||
destfn = beets.config['statefile'].as_filename()
|
||||
if os.path.exists(destfn):
|
||||
if replace:
|
||||
_displace(destfn)
|
||||
else:
|
||||
return
|
||||
|
||||
log.debug(u'copying state file from {0} to {1}'.format(
|
||||
util.displayable_path(srcfn), util.displayable_path(destfn)
|
||||
))
|
||||
util.copy(srcfn, destfn)
|
||||
return destfn
|
||||
|
||||
|
||||
# Automatic migration when beets starts.
|
||||
|
||||
def automigrate():
|
||||
"""Migrate the configuration, database, and state files. If any
|
||||
migration occurs, print out a notice with some helpful next steps.
|
||||
"""
|
||||
config_fn = migrate_config()
|
||||
db_fn = migrate_db()
|
||||
migrate_state()
|
||||
|
||||
if config_fn:
|
||||
ui.print_(ui.colorize('fuchsia', u'MIGRATED CONFIGURATION'))
|
||||
|
||||
ui.print_(CONFIG_MIGRATED_MESSAGE.format(
|
||||
newconfig=util.displayable_path(config_fn))
|
||||
)
|
||||
if db_fn:
|
||||
ui.print_(DB_MIGRATED_MESSAGE.format(
|
||||
newdb=util.displayable_path(db_fn)
|
||||
))
|
||||
|
||||
ui.input_(ui.colorize('fuchsia', u'Press ENTER to continue:'))
|
||||
ui.print_()
|
||||
|
||||
|
||||
# CLI command for explicit migration.
|
||||
|
||||
migrate_cmd = ui.Subcommand('migrate', help='convert legacy config')
|
||||
def migrate_func(lib, opts, args):
|
||||
"""Explicit command for migrating files. Existing files in each
|
||||
destination are moved aside.
|
||||
"""
|
||||
config_fn = migrate_config(replace=True)
|
||||
if config_fn:
|
||||
log.info(u'Migrated configuration to: {0}'.format(
|
||||
util.displayable_path(config_fn)
|
||||
))
|
||||
db_fn = migrate_db(replace=True)
|
||||
if db_fn:
|
||||
log.info(u'Migrated library database to: {0}'.format(
|
||||
util.displayable_path(db_fn)
|
||||
))
|
||||
state_fn = migrate_state(replace=True)
|
||||
if state_fn:
|
||||
log.info(u'Migrated state file to: {0}'.format(
|
||||
util.displayable_path(state_fn)
|
||||
))
|
||||
migrate_cmd.func = migrate_func
|
||||
Reference in New Issue
Block a user