diff --git a/beets/importer.py b/beets/importer.py index fe6ecb7d9..3eb472cf3 100644 --- a/beets/importer.py +++ b/beets/importer.py @@ -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 diff --git a/beets/mediafile.py b/beets/mediafile.py index b1bc74ca0..6d5d7c8fc 100644 --- a/beets/mediafile.py +++ b/beets/mediafile.py @@ -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 diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 18291f3ed..be2e227ac 100755 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -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 diff --git a/beets/util/enumeration.py b/beets/util/enumeration.py new file mode 100644 index 000000000..73dee8777 --- /dev/null +++ b/beets/util/enumeration.py @@ -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}) diff --git a/test/_common.py b/test/_common.py index f6628634a..9189964d8 100644 --- a/test/_common.py +++ b/test/_common.py @@ -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, diff --git a/test/test_importer.py b/test/test_importer.py index 2b710cc02..be6ecb954 100644 --- a/test/test_importer.py +++ b/test/test_importer.py @@ -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') ) diff --git a/test/test_ui.py b/test/test_ui.py index 874899fbe..940613f11 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -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):