add and use fancy enumeration module

This commit is contained in:
Adrian Sampson 2011-04-10 22:10:33 -07:00
parent 6884fc230f
commit a675988eb2
7 changed files with 250 additions and 63 deletions

View file

@ -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

View file

@ -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

View file

@ -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
View 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})

View file

@ -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,

View file

@ -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')
)

View file

@ -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):