mirror of
https://github.com/beetbox/beets.git
synced 2026-01-03 14:32:55 +01:00
add and use fancy enumeration module
This commit is contained in:
parent
6884fc230f
commit
a675988eb2
7 changed files with 250 additions and 63 deletions
|
|
@ -26,12 +26,12 @@ import beets.autotag.art
|
|||
from beets import plugins
|
||||
from beets.util import pipeline
|
||||
from beets.util import syspath, normpath
|
||||
from beets.util.enumeration import enum
|
||||
|
||||
CHOICE_SKIP = 'CHOICE_SKIP'
|
||||
CHOICE_ASIS = 'CHOICE_ASIS'
|
||||
CHOICE_TRACKS = 'CHOICE_TRACKS'
|
||||
CHOICE_MANUAL = 'CHOICE_MANUAL'
|
||||
CHOICE_ALBUM = 'CHOICE_ALBUM'
|
||||
action = enum(
|
||||
'SKIP', 'ASIS', 'TRACKS', 'MANUAL', 'ALBUM',
|
||||
name='action'
|
||||
)
|
||||
|
||||
QUEUE_SIZE = 128
|
||||
STATE_FILE = os.path.expanduser('~/.beetsstate')
|
||||
|
|
@ -165,23 +165,23 @@ class ImportTask(object):
|
|||
self.set_match(None, None, None, None)
|
||||
|
||||
def set_choice(self, choice):
|
||||
"""Given either an (info, items) tuple or a CHOICE_ constant,
|
||||
"""Given either an (info, items) tuple or an action constant,
|
||||
indicates that an action has been selected by the user (or
|
||||
automatically).
|
||||
"""
|
||||
assert not self.sentinel
|
||||
assert choice != CHOICE_MANUAL # Not part of the task structure.
|
||||
assert choice != CHOICE_ALBUM # Only used internally.
|
||||
if choice in (CHOICE_SKIP, CHOICE_ASIS, CHOICE_TRACKS):
|
||||
assert choice != action.MANUAL # Not part of the task structure.
|
||||
assert choice != action.ALBUM # Only used internally.
|
||||
if choice in (action.SKIP, action.ASIS, action.TRACKS):
|
||||
self.choice_flag = choice
|
||||
self.info = None
|
||||
if choice == CHOICE_SKIP:
|
||||
if choice == action.SKIP:
|
||||
self.items = None # Items no longer needed.
|
||||
else:
|
||||
info, items = choice
|
||||
self.items = items # Reordered items list.
|
||||
self.info = info
|
||||
self.choice_flag = CHOICE_ALBUM # Implicit choice.
|
||||
self.choice_flag = action.ALBUM # Implicit choice.
|
||||
|
||||
def save_progress(self):
|
||||
"""Updates the progress state to indicate that this album has
|
||||
|
|
@ -194,17 +194,17 @@ class ImportTask(object):
|
|||
|
||||
def should_create_album(self):
|
||||
"""Should an album structure be created for these items?"""
|
||||
if self.choice_flag in (CHOICE_ALBUM, CHOICE_ASIS):
|
||||
if self.choice_flag in (action.ALBUM, action.ASIS):
|
||||
return True
|
||||
elif self.choice_flag in (CHOICE_TRACKS, CHOICE_SKIP):
|
||||
elif self.choice_flag in (action.TRACKS, action.SKIP):
|
||||
return False
|
||||
else:
|
||||
assert False
|
||||
def should_write_tags(self):
|
||||
"""Should new info be written to the files' metadata?"""
|
||||
if self.choice_flag == CHOICE_ALBUM:
|
||||
if self.choice_flag == action.ALBUM:
|
||||
return True
|
||||
elif self.choice_flag in (CHOICE_ASIS, CHOICE_TRACKS, CHOICE_SKIP):
|
||||
elif self.choice_flag in (action.ASIS, action.TRACKS, action.SKIP):
|
||||
return False
|
||||
else:
|
||||
assert False
|
||||
|
|
@ -216,10 +216,10 @@ class ImportTask(object):
|
|||
field be inferred from the plurality of track artists?
|
||||
"""
|
||||
assert self.should_create_album()
|
||||
if self.choice_flag == CHOICE_ALBUM:
|
||||
if self.choice_flag == action.ALBUM:
|
||||
# Album artist comes from the info dictionary.
|
||||
return False
|
||||
elif self.choice_flag == CHOICE_ASIS:
|
||||
elif self.choice_flag == action.ASIS:
|
||||
# As-is imports likely don't have an album artist.
|
||||
return True
|
||||
else:
|
||||
|
|
@ -313,14 +313,14 @@ def user_query(config):
|
|||
task.set_choice(choice)
|
||||
|
||||
# Log certain choices.
|
||||
if choice is CHOICE_ASIS:
|
||||
if choice is action.ASIS:
|
||||
tag_log(config.logfile, 'asis', task.path)
|
||||
elif choice is CHOICE_SKIP:
|
||||
elif choice is action.SKIP:
|
||||
tag_log(config.logfile, 'skip', task.path)
|
||||
|
||||
# Check for duplicates if we have a match.
|
||||
if choice == CHOICE_ASIS or isinstance(choice, tuple):
|
||||
if choice == CHOICE_ASIS:
|
||||
if choice == action.ASIS or isinstance(choice, tuple):
|
||||
if choice == action.ASIS:
|
||||
artist = task.cur_artist
|
||||
album = task.cur_album
|
||||
else:
|
||||
|
|
@ -329,7 +329,7 @@ def user_query(config):
|
|||
if _duplicate_check(lib, artist, album):
|
||||
tag_log(config.logfile, 'duplicate', task.path)
|
||||
log.warn("This album is already in the library!")
|
||||
task.set_choice(CHOICE_SKIP)
|
||||
task.set_choice(action.SKIP)
|
||||
|
||||
def apply_choices(config):
|
||||
"""A coroutine for applying changes to albums during the autotag
|
||||
|
|
@ -341,7 +341,7 @@ def apply_choices(config):
|
|||
while True:
|
||||
task = yield
|
||||
# Don't do anything if we're skipping the album or we're done.
|
||||
if task.choice_flag == CHOICE_SKIP or task.sentinel:
|
||||
if task.choice_flag == action.SKIP or task.sentinel:
|
||||
if config.resume is not False:
|
||||
task.save_progress()
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import mutagen.flac
|
|||
import mutagen.monkeysaudio
|
||||
import datetime
|
||||
import re
|
||||
from beets.util.enumeration import enum
|
||||
|
||||
__all__ = ['UnreadableFileError', 'FileTypeError', 'MediaFile']
|
||||
|
||||
|
|
@ -113,15 +114,11 @@ def _safe_cast(out_type, val):
|
|||
|
||||
# Flags for encoding field behavior.
|
||||
|
||||
class Enumeration(object):
|
||||
def __init__(self, *values):
|
||||
for value, equiv in zip(values, range(1, len(values)+1)):
|
||||
setattr(self, value, equiv)
|
||||
# determine style of packing if any
|
||||
packing = Enumeration('SLASHED', # pair delimited by /
|
||||
'TUPLE', # a python tuple of 2 items
|
||||
'DATE' # YYYY-MM-DD
|
||||
)
|
||||
# Determine style of packing, if any.
|
||||
packing = enum('SLASHED', # pair delimited by /
|
||||
'TUPLE', # a python tuple of 2 items
|
||||
'DATE', # YYYY-MM-DD
|
||||
name='packing')
|
||||
|
||||
class StorageStyle(object):
|
||||
"""Parameterizes the storage behavior of a single field for a
|
||||
|
|
|
|||
|
|
@ -134,8 +134,7 @@ def choose_candidate(cur_artist, cur_album, candidates, rec, color=True):
|
|||
of which candidate to use. Returns a pair (candidate, ordered)
|
||||
consisting of the the selected candidate and the associated track
|
||||
ordering. If user chooses to skip, use as-is, or search manually,
|
||||
returns CHOICE_SKIP, CHOICE_ASIS, CHOICE_TRACKS, or CHOICE_MANUAL
|
||||
instead of a tuple.
|
||||
returns SKIP, ASIS, TRACKS, or MANUAL instead of a tuple.
|
||||
"""
|
||||
# Zero candidates.
|
||||
if not candidates:
|
||||
|
|
@ -147,13 +146,13 @@ def choose_candidate(cur_artist, cur_album, candidates, rec, color=True):
|
|||
'Enter U, T, S, E, or B:'
|
||||
)
|
||||
if sel == 'u':
|
||||
return importer.CHOICE_ASIS
|
||||
return importer.action.ASIS
|
||||
elif sel == 't':
|
||||
return importer.CHOICE_TRACKS
|
||||
return importer.action.TRACKS
|
||||
elif sel == 'e':
|
||||
return importer.CHOICE_MANUAL
|
||||
return importer.action.MANUAL
|
||||
elif sel == 's':
|
||||
return importer.CHOICE_SKIP
|
||||
return importer.action.SKIP
|
||||
elif sel == 'b':
|
||||
raise importer.ImportAbort()
|
||||
else:
|
||||
|
|
@ -184,13 +183,13 @@ def choose_candidate(cur_artist, cur_album, candidates, rec, color=True):
|
|||
(1, len(candidates))
|
||||
)
|
||||
if sel == 's':
|
||||
return importer.CHOICE_SKIP
|
||||
return importer.action.SKIP
|
||||
elif sel == 'u':
|
||||
return importer.CHOICE_ASIS
|
||||
return importer.action.ASIS
|
||||
elif sel == 'e':
|
||||
return importer.CHOICE_MANUAL
|
||||
return importer.action.MANUAL
|
||||
elif sel == 't':
|
||||
return importer.CHOICE_TRACKS
|
||||
return importer.action.TRACKS
|
||||
elif sel == 'b':
|
||||
raise importer.ImportAbort()
|
||||
else: # Numerical selection.
|
||||
|
|
@ -216,13 +215,13 @@ def choose_candidate(cur_artist, cur_album, candidates, rec, color=True):
|
|||
elif sel == 'm':
|
||||
pass
|
||||
elif sel == 's':
|
||||
return importer.CHOICE_SKIP
|
||||
return importer.action.SKIP
|
||||
elif sel == 'u':
|
||||
return importer.CHOICE_ASIS
|
||||
return importer.action.ASIS
|
||||
elif sel == 't':
|
||||
return importer.CHOICE_TRACKS
|
||||
return importer.action.TRACKS
|
||||
elif sel == 'e':
|
||||
return importer.CHOICE_MANUAL
|
||||
return importer.action.MANUAL
|
||||
elif sel == 'b':
|
||||
raise importer.ImportAbort()
|
||||
|
||||
|
|
@ -235,7 +234,7 @@ def manual_search():
|
|||
def choose_match(task, config):
|
||||
"""Given an initial autotagging of items, go through an interactive
|
||||
dance with the user to ask for a choice of metadata. Returns an
|
||||
(info, items) pair, CHOICE_ASIS, or CHOICE_SKIP.
|
||||
(info, items) pair, ASIS, or SKIP.
|
||||
"""
|
||||
# Show what we're tagging.
|
||||
print_()
|
||||
|
|
@ -249,9 +248,9 @@ def choose_match(task, config):
|
|||
config.color)
|
||||
return info, items
|
||||
else:
|
||||
if config.quiet_fallback == importer.CHOICE_SKIP:
|
||||
if config.quiet_fallback == importer.action.SKIP:
|
||||
print_('Skipping.')
|
||||
elif config.quiet_fallback == importer.CHOICE_ASIS:
|
||||
elif config.quiet_fallback == importer.action.ASIS:
|
||||
print_('Importing as-is.')
|
||||
else:
|
||||
assert(False)
|
||||
|
|
@ -264,11 +263,11 @@ def choose_match(task, config):
|
|||
task.candidates, task.rec, config.color)
|
||||
|
||||
# Choose which tags to use.
|
||||
if choice in (importer.CHOICE_SKIP, importer.CHOICE_ASIS,
|
||||
importer.CHOICE_TRACKS):
|
||||
if choice in (importer.action.SKIP, importer.action.ASIS,
|
||||
importer.action.TRACKS):
|
||||
# Pass selection to main control flow.
|
||||
return choice
|
||||
elif choice is importer.CHOICE_MANUAL:
|
||||
elif choice is importer.action.MANUAL:
|
||||
# Try again with manual search terms.
|
||||
search_artist, search_album = manual_search()
|
||||
try:
|
||||
|
|
@ -298,9 +297,8 @@ def import_files(lib, paths, copy, write, autot, logpath, art, threaded,
|
|||
never prompted for input; instead, the tagger just skips anything
|
||||
it is not confident about. resume indicates whether interrupted
|
||||
imports can be resumed and is either a boolean or None.
|
||||
quiet_fallback should be either CHOICE_ASIS or CHOICE_SKIP and
|
||||
indicates what should happen in quiet mode when the recommendation
|
||||
is not strong.
|
||||
quiet_fallback should be either ASIS or SKIP and indicates what
|
||||
should happen in quiet mode when the recommendation is not strong.
|
||||
"""
|
||||
# Check the user-specified directories.
|
||||
for path in paths:
|
||||
|
|
@ -402,9 +400,9 @@ def import_func(lib, config, opts, args):
|
|||
resume = None
|
||||
|
||||
if quiet_fallback_str == 'asis':
|
||||
quiet_fallback = importer.CHOICE_ASIS
|
||||
quiet_fallback = importer.action.ASIS
|
||||
else:
|
||||
quiet_fallback = importer.CHOICE_SKIP
|
||||
quiet_fallback = importer.action.SKIP
|
||||
import_files(lib, args, copy, write, autot, opts.logpath, art, threaded,
|
||||
color, delete, quiet, resume, quiet_fallback)
|
||||
import_cmd.func = import_func
|
||||
|
|
|
|||
178
beets/util/enumeration.py
Normal file
178
beets/util/enumeration.py
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
# This file is part of beets.
|
||||
# Copyright 2011, 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.
|
||||
|
||||
"""A metaclass for enumerated types that really are types.
|
||||
|
||||
You can create enumerations with `enum(values, [name])` and they work
|
||||
how you would expect them to.
|
||||
|
||||
>>> from enumeration import enum
|
||||
>>> Direction = enum('north east south west', name='Direction')
|
||||
>>> Direction.west
|
||||
Direction.west
|
||||
>>> Direction.west == Direction.west
|
||||
True
|
||||
>>> Direction.west == Direction.east
|
||||
False
|
||||
>>> isinstance(Direction.west, Direction)
|
||||
True
|
||||
>>> Direction[3]
|
||||
Direction.west
|
||||
>>> Direction['west']
|
||||
Direction.west
|
||||
>>> Direction.west.name
|
||||
'west'
|
||||
>>> Direction.north < Direction.west
|
||||
True
|
||||
|
||||
Enumerations are classes; their instances represent the possible values
|
||||
of the enumeration. Because Python classes must have names, you may
|
||||
provide a `name` parameter to `enum`; if you don't, a meaningless one
|
||||
will be chosen for you.
|
||||
"""
|
||||
import random
|
||||
|
||||
class Enumeration(type):
|
||||
"""A metaclass whose classes are enumerations.
|
||||
|
||||
The `values` attribute of the class is used to populate the
|
||||
enumeration. Values may either be a list of enumerated names or a
|
||||
string containing a space-separated list of names. When the class
|
||||
is created, it is instantiated for each name value in `values`.
|
||||
Each such instance is the name of the enumerated item as the sole
|
||||
argument.
|
||||
|
||||
The `Enumerated` class is a good choice for a superclass.
|
||||
"""
|
||||
|
||||
def __init__(cls, name, bases, dic):
|
||||
super(Enumeration, cls).__init__(name, bases, dic)
|
||||
|
||||
if not hasattr(cls, 'values'):
|
||||
# Do nothing if no values are provided (i.e., with
|
||||
# Enumerated itself).
|
||||
return
|
||||
|
||||
# May be called with a single string, in which case we split on
|
||||
# whitespace for convenience.
|
||||
values = cls.values
|
||||
if isinstance(values, basestring):
|
||||
values = values.split()
|
||||
|
||||
# Create the Enumerated instances for each value. We have to use
|
||||
# super's __setattr__ here because we disallow setattr below.
|
||||
super(Enumeration, cls).__setattr__('_items_dict', {})
|
||||
super(Enumeration, cls).__setattr__('_items_list', [])
|
||||
for value in values:
|
||||
item = cls(value, len(cls._items_list))
|
||||
cls._items_dict[value] = item
|
||||
cls._items_list.append(item)
|
||||
|
||||
def __getattr__(cls, key):
|
||||
try:
|
||||
return cls._items_dict[key]
|
||||
except KeyError:
|
||||
raise AttributeError("enumeration '" + cls.__name__ +
|
||||
"' has no item '" + key + "'")
|
||||
|
||||
def __setattr__(cls, key, val):
|
||||
raise TypeError("enumerations do not support attribute assignment")
|
||||
|
||||
def __getitem__(cls, key):
|
||||
if isinstance(key, int):
|
||||
return cls._items_list[key]
|
||||
else:
|
||||
return getattr(cls, key)
|
||||
|
||||
def __len__(cls):
|
||||
return len(cls._items_list)
|
||||
|
||||
def __iter__(cls):
|
||||
return iter(cls._items_list)
|
||||
|
||||
def __nonzero__(cls):
|
||||
# Ensures that __len__ doesn't get called before __init__ by
|
||||
# pydoc.
|
||||
return True
|
||||
|
||||
class Enumerated(object):
|
||||
"""An item in an enumeration.
|
||||
|
||||
Contains instance methods inherited by enumerated objects. The
|
||||
metaclass is preset to `Enumeration` for your convenience.
|
||||
|
||||
Instance attributes:
|
||||
name -- The name of the item.
|
||||
index -- The index of the item in its enumeration.
|
||||
|
||||
>>> from enumeration import Enumerated
|
||||
>>> class Garment(Enumerated):
|
||||
... values = 'hat glove belt poncho lederhosen suspenders'
|
||||
... def wear(self):
|
||||
... print 'now wearing a ' + self.name
|
||||
...
|
||||
>>> Garment.poncho.wear()
|
||||
now wearing a poncho
|
||||
"""
|
||||
|
||||
__metaclass__ = Enumeration
|
||||
|
||||
def __init__(self, name, index):
|
||||
self.name = name
|
||||
self.index = index
|
||||
|
||||
def __str__(self):
|
||||
return type(self).__name__ + '.' + self.name
|
||||
|
||||
def __repr__(self):
|
||||
return str(self)
|
||||
|
||||
def __cmp__(self, other):
|
||||
if type(self) is type(other):
|
||||
# Note that we're assuming that the items are direct
|
||||
# instances of the same Enumeration (i.e., no fancy
|
||||
# subclassing), which is probably okay.
|
||||
return cmp(self.index, other.index)
|
||||
else:
|
||||
return NotImplemented
|
||||
|
||||
def enum(*values, **kwargs):
|
||||
"""Shorthand for creating a new Enumeration class.
|
||||
|
||||
Call with enumeration values as a list, a space-delimited string, or
|
||||
just an argument list. To give the class a name, pass it as the
|
||||
`name` keyword argument. Otherwise, a name will be chosen for you.
|
||||
|
||||
The following are all equivalent:
|
||||
|
||||
enum('pinkie ring middle index thumb')
|
||||
enum('pinkie', 'ring', 'middle', 'index', 'thumb')
|
||||
enum(['pinkie', 'ring', 'middle', 'index', 'thumb'])
|
||||
"""
|
||||
|
||||
if ('name' not in kwargs) or kwargs['name'] is None:
|
||||
# Create a probably-unique name. It doesn't really have to be
|
||||
# unique, but getting distinct names each time helps with
|
||||
# identification in debugging.
|
||||
name = 'Enumeration' + hex(random.randint(0,0xfffffff))[2:].upper()
|
||||
else:
|
||||
name = kwargs['name']
|
||||
|
||||
if len(values) == 1:
|
||||
# If there's only one value, we have a couple of alternate calling
|
||||
# styles.
|
||||
if isinstance(values[0], basestring) or hasattr(values[0], '__iter__'):
|
||||
values = values[0]
|
||||
|
||||
return type(name, (Enumerated,), {'values': values})
|
||||
|
|
@ -1,4 +1,18 @@
|
|||
"""Some common functionality for beets' test cases."""
|
||||
# This file is part of beets.
|
||||
# Copyright 2011, 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.
|
||||
|
||||
import time
|
||||
import sys
|
||||
import os
|
||||
|
|
@ -48,12 +62,12 @@ def iconfig(lib, **kwargs):
|
|||
logfile = None,
|
||||
color = False,
|
||||
quiet = True,
|
||||
quiet_fallback = importer.CHOICE_SKIP,
|
||||
quiet_fallback = importer.action.SKIP,
|
||||
copy = True,
|
||||
write = False,
|
||||
art = False,
|
||||
delete = False,
|
||||
choose_match_func = lambda x, y: importer.CHOICE_SKIP,
|
||||
choose_match_func = lambda x, y: importer.action.SKIP,
|
||||
should_resume_func = lambda _: False,
|
||||
threaded = False,
|
||||
autot = True,
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ class ImportApplyTest(unittest.TestCase, _common.ExtraAsserts):
|
|||
def test_apply_asis_uses_album_path(self):
|
||||
coro = importer.apply_choices(_common.iconfig(self.lib))
|
||||
coro.next() # Prime coroutine.
|
||||
self._call_apply_choice(coro, [self.i], importer.CHOICE_ASIS)
|
||||
self._call_apply_choice(coro, [self.i], importer.action.ASIS)
|
||||
self.assertExists(
|
||||
os.path.join(self.libdir, self.lib.path_formats['default']+'.mp3')
|
||||
)
|
||||
|
|
@ -94,7 +94,7 @@ class ImportApplyTest(unittest.TestCase, _common.ExtraAsserts):
|
|||
def test_apply_as_tracks_uses_singleton_path(self):
|
||||
coro = importer.apply_choices(_common.iconfig(self.lib))
|
||||
coro.next() # Prime coroutine.
|
||||
self._call_apply_choice(coro, [self.i], importer.CHOICE_TRACKS)
|
||||
self._call_apply_choice(coro, [self.i], importer.action.TRACKS)
|
||||
self.assertExists(
|
||||
os.path.join(self.libdir, self.lib.path_formats['singleton']+'.mp3')
|
||||
)
|
||||
|
|
|
|||
|
|
@ -261,11 +261,11 @@ class AutotagTest(unittest.TestCase):
|
|||
|
||||
def test_choose_match_with_no_candidates_skip(self):
|
||||
self.io.addinput('s')
|
||||
self._no_candidates_test(importer.CHOICE_SKIP)
|
||||
self._no_candidates_test(importer.action.SKIP)
|
||||
|
||||
def test_choose_match_with_no_candidates_asis(self):
|
||||
self.io.addinput('u')
|
||||
self._no_candidates_test(importer.CHOICE_ASIS)
|
||||
self._no_candidates_test(importer.action.ASIS)
|
||||
|
||||
class InputTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
|
|
|
|||
Loading…
Reference in a new issue