From 7e59a5aecbb847a0eb269b8ccb71b196c8459a69 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 25 Apr 2015 00:06:53 +0200 Subject: [PATCH 1/3] models: Split models into smaller modules --- mopidy/models.py | 672 ------------------------------------ mopidy/models/__init__.py | 352 +++++++++++++++++++ mopidy/models/fields.py | 125 +++++++ mopidy/models/immutable.py | 160 +++++++++ mopidy/models/serialize.py | 46 +++ tests/models/test_fields.py | 3 +- 6 files changed, 685 insertions(+), 673 deletions(-) delete mode 100644 mopidy/models.py create mode 100644 mopidy/models/__init__.py create mode 100644 mopidy/models/fields.py create mode 100644 mopidy/models/immutable.py create mode 100644 mopidy/models/serialize.py diff --git a/mopidy/models.py b/mopidy/models.py deleted file mode 100644 index f4404fb8..00000000 --- a/mopidy/models.py +++ /dev/null @@ -1,672 +0,0 @@ -from __future__ import absolute_import, unicode_literals - -import copy -import inspect -import itertools -import json -import weakref - -from mopidy.utils import deprecation - -# TODO: split into base models, serialization and fields? - - -class Field(object): - - """ - Base field for use in :class:`ImmutableObject`. These fields are - responsible for type checking and other data sanitation in our models. - - For simplicity fields use the Python descriptor protocol to store the - values in the instance dictionary. Also note that fields are mutable if - the object they are attached to allow it. - - Default values will be validated with the exception of :class:`None`. - - :param default: default value for field - :param type: if set the field value must be of this type - :param choices: if set the field value must be one of these - """ - - def __init__(self, default=None, type=None, choices=None): - self._name = None # Set by ImmutableObjectMeta - self._choices = choices - self._default = default - self._type = type - - if self._default is not None: - self.validate(self._default) - - def validate(self, value): - """Validate and possibly modify the field value before assignment""" - if self._type and not isinstance(value, self._type): - raise TypeError('Expected %s to be a %s, not %r' % - (self._name, self._type, value)) - if self._choices and value not in self._choices: - raise TypeError('Expected %s to be a one of %s, not %r' % - (self._name, self._choices, value)) - return value - - def __get__(self, instance, owner): - if not instance: - return self - return getattr(instance, '_' + self._name, self._default) - - def __set__(self, instance, value): - if value is not None: - value = self.validate(value) - - if value is None or value == self._default: - self.__delete__(instance) - else: - setattr(instance, '_' + self._name, value) - - def __delete__(self, instance): - if hasattr(instance, '_' + self._name): - delattr(instance, '_' + self._name) - - -class String(Field): - - """ - Specialized :class:`Field` which is wired up for bytes and unicode. - - :param default: default value for field - """ - - def __init__(self, default=None): - # TODO: normalize to unicode? - # TODO: only allow unicode? - # TODO: disallow empty strings? - super(String, self).__init__(type=basestring, default=default) - - -class Identifier(String): - def validate(self, value): - return intern(str(super(Identifier, self).validate(value))) - - -class Integer(Field): - - """ - :class:`Field` for storing integer numbers. - - :param default: default value for field - :param min: field value must be larger or equal to this value when set - :param max: field value must be smaller or equal to this value when set - """ - - def __init__(self, default=None, min=None, max=None): - self._min = min - self._max = max - super(Integer, self).__init__(type=(int, long), default=default) - - def validate(self, value): - value = super(Integer, self).validate(value) - if self._min is not None and value < self._min: - raise ValueError('Expected %s to be at least %d, not %d' % - (self._name, self._min, value)) - if self._max is not None and value > self._max: - raise ValueError('Expected %s to be at most %d, not %d' % - (self._name, self._max, value)) - return value - - -class Collection(Field): - - """ - :class:`Field` for storing collections of a given type. - - :param type: all items stored in the collection must be of this type - :param container: the type to store the items in - """ - - def __init__(self, type, container=tuple): - super(Collection, self).__init__(type=type, default=container()) - - def validate(self, value): - if isinstance(value, basestring): - raise TypeError('Expected %s to be a collection of %s, not %r' - % (self._name, self._type.__name__, value)) - for v in value: - if not isinstance(v, self._type): - raise TypeError('Expected %s to be a collection of %s, not %r' - % (self._name, self._type.__name__, value)) - return self._default.__class__(value) or None - - -class ImmutableObjectMeta(type): - - """Helper to automatically assign field names to descriptors.""" - - def __new__(cls, name, bases, attrs): - fields = {} - for key, value in attrs.items(): - if isinstance(value, Field): - fields[key] = '_' + key - value._name = key - - attrs['_fields'] = fields - attrs['_instances'] = weakref.WeakValueDictionary() - attrs['__slots__'] = ['_hash'] + fields.values() - - for ancestor in [b for base in bases for b in inspect.getmro(base)]: - if '__weakref__' in getattr(ancestor, '__slots__', []): - break - else: - attrs['__slots__'].append('__weakref__') - - return super(ImmutableObjectMeta, cls).__new__(cls, name, bases, attrs) - - def __call__(cls, *args, **kwargs): # noqa: N805 - instance = super(ImmutableObjectMeta, cls).__call__(*args, **kwargs) - return cls._instances.setdefault(weakref.ref(instance), instance) - - -class ImmutableObject(object): - - """ - Superclass for immutable objects whose fields can only be modified via the - constructor. Fields should be :class:`Field` instances to ensure type - safety in our models. - - Note that since these models can not be changed, we heavily memoize them - to save memory. So constructing a class with the same arguments twice will - give you the same instance twice. - - :param kwargs: kwargs to set as fields on the object - :type kwargs: any - """ - - __metaclass__ = ImmutableObjectMeta - - def __init__(self, *args, **kwargs): - for key, value in kwargs.items(): - if key not in self._fields: - raise TypeError( - '__init__() got an unexpected keyword argument "%s"' % - key) - super(ImmutableObject, self).__setattr__(key, value) - - def __setattr__(self, name, value): - if name in self.__slots__: - return super(ImmutableObject, self).__setattr__(name, value) - raise AttributeError('Object is immutable.') - - def __delattr__(self, name): - if name in self.__slots__: - return super(ImmutableObject, self).__delattr__(name) - raise AttributeError('Object is immutable.') - - def _items(self): - for field, key in self._fields.items(): - if hasattr(self, key): - yield field, getattr(self, key) - - def __repr__(self): - kwarg_pairs = [] - for key, value in sorted(self._items()): - if isinstance(value, (frozenset, tuple)): - if not value: - continue - value = list(value) - kwarg_pairs.append('%s=%s' % (key, repr(value))) - return '%(classname)s(%(kwargs)s)' % { - 'classname': self.__class__.__name__, - 'kwargs': ', '.join(kwarg_pairs), - } - - def __hash__(self): - if not hasattr(self, '_hash'): - hash_sum = 0 - for key, value in self._items(): - hash_sum += hash(key) + hash(value) - super(ImmutableObject, self).__setattr__('_hash', hash_sum) - return self._hash - - def __eq__(self, other): - if not isinstance(other, self.__class__): - return False - return all(a == b for a, b in itertools.izip_longest( - self._items(), other._items(), fillvalue=object())) - - def __ne__(self, other): - return not self.__eq__(other) - - def copy(self, **values): - """ - .. deprecated:: 1.1 - Use :meth:`replace` instead. Note that we no longer return copies. - """ - deprecation.warn('model.immutable.copy') - return self.replace(**values) - - def replace(self, **kwargs): - """ - Replace the fields in the model and return a new instance - - Examples:: - - # Returns a track with a new name - Track(name='foo').replace(name='bar') - # Return an album with a new number of tracks - Album(num_tracks=2).replace(num_tracks=5) - - Note that internally we memoize heavily to keep memory usage down given - our overly repetitive data structures. So you might get an existing - instance if it contains the same values. - - :param kwargs: kwargs to set as fields on the object - :type kwargs: any - :rtype: instance of the model with replaced fields - """ - if not kwargs: - return self - other = copy.copy(self) - for key, value in kwargs.items(): - if key not in self._fields: - raise TypeError( - 'copy() got an unexpected keyword argument "%s"' % key) - super(ImmutableObject, other).__setattr__(key, value) - super(ImmutableObject, other).__delattr__('_hash') - return self._instances.setdefault(weakref.ref(other), other) - - def serialize(self): - data = {} - data['__model__'] = self.__class__.__name__ - for key, value in self._items(): - if isinstance(value, (set, frozenset, list, tuple)): - value = [ - v.serialize() if isinstance(v, ImmutableObject) else v - for v in value] - elif isinstance(value, ImmutableObject): - value = value.serialize() - if not (isinstance(value, list) and len(value) == 0): - data[key] = value - return data - - -class ModelJSONEncoder(json.JSONEncoder): - - """ - Automatically serialize Mopidy models to JSON. - - Usage:: - - >>> import json - >>> json.dumps({'a_track': Track(name='name')}, cls=ModelJSONEncoder) - '{"a_track": {"__model__": "Track", "name": "name"}}' - - """ - - def default(self, obj): - if isinstance(obj, ImmutableObject): - return obj.serialize() - return json.JSONEncoder.default(self, obj) - - -def model_json_decoder(dct): - """ - Automatically deserialize Mopidy models from JSON. - - Usage:: - - >>> import json - >>> json.loads( - ... '{"a_track": {"__model__": "Track", "name": "name"}}', - ... object_hook=model_json_decoder) - {u'a_track': Track(artists=[], name=u'name')} - - """ - if '__model__' in dct: - # TODO: move models to a global constant once we split this module - models = {c.__name__: c for c in ImmutableObject.__subclasses__()} - model_name = dct.pop('__model__') - if model_name in models: - return models[model_name](**dct) - return dct - - -class Ref(ImmutableObject): - - """ - Model to represent URI references with a human friendly name and type - attached. This is intended for use a lightweight object "free" of metadata - that can be passed around instead of using full blown models. - - :param uri: object URI - :type uri: string - :param name: object name - :type name: string - :param type: object type - :type type: string - """ - - #: The object URI. Read-only. - uri = Identifier() - - #: The object name. Read-only. - name = String() - - #: Constant used for comparison with the :attr:`type` field. - ALBUM = 'album' - - #: Constant used for comparison with the :attr:`type` field. - ARTIST = 'artist' - - #: Constant used for comparison with the :attr:`type` field. - DIRECTORY = 'directory' - - #: Constant used for comparison with the :attr:`type` field. - PLAYLIST = 'playlist' - - #: Constant used for comparison with the :attr:`type` field. - TRACK = 'track' - - #: The object type, e.g. "artist", "album", "track", "playlist", - #: "directory". Read-only. - type = Field(choices=(ALBUM, ARTIST, DIRECTORY, PLAYLIST, TRACK)) - - @classmethod - def album(cls, **kwargs): - """Create a :class:`Ref` with ``type`` :attr:`ALBUM`.""" - kwargs['type'] = Ref.ALBUM - return cls(**kwargs) - - @classmethod - def artist(cls, **kwargs): - """Create a :class:`Ref` with ``type`` :attr:`ARTIST`.""" - kwargs['type'] = Ref.ARTIST - return cls(**kwargs) - - @classmethod - def directory(cls, **kwargs): - """Create a :class:`Ref` with ``type`` :attr:`DIRECTORY`.""" - kwargs['type'] = Ref.DIRECTORY - return cls(**kwargs) - - @classmethod - def playlist(cls, **kwargs): - """Create a :class:`Ref` with ``type`` :attr:`PLAYLIST`.""" - kwargs['type'] = Ref.PLAYLIST - return cls(**kwargs) - - @classmethod - def track(cls, **kwargs): - """Create a :class:`Ref` with ``type`` :attr:`TRACK`.""" - kwargs['type'] = Ref.TRACK - return cls(**kwargs) - - -class Image(ImmutableObject): - - """ - :param string uri: URI of the image - :param int width: Optional width of image or :class:`None` - :param int height: Optional height of image or :class:`None` - """ - - #: The image URI. Read-only. - uri = Identifier() - - #: Optional width of the image or :class:`None`. Read-only. - width = Integer(min=0) - - #: Optional height of the image or :class:`None`. Read-only. - height = Integer(min=0) - - -class Artist(ImmutableObject): - - """ - :param uri: artist URI - :type uri: string - :param name: artist name - :type name: string - :param musicbrainz_id: MusicBrainz ID - :type musicbrainz_id: string - """ - - #: The artist URI. Read-only. - uri = Identifier() - - #: The artist name. Read-only. - name = String() - - #: The MusicBrainz ID of the artist. Read-only. - musicbrainz_id = Identifier() - - -class Album(ImmutableObject): - - """ - :param uri: album URI - :type uri: string - :param name: album name - :type name: string - :param artists: album artists - :type artists: list of :class:`Artist` - :param num_tracks: number of tracks in album - :type num_tracks: integer or :class:`None` if unknown - :param num_discs: number of discs in album - :type num_discs: integer or :class:`None` if unknown - :param date: album release date (YYYY or YYYY-MM-DD) - :type date: string - :param musicbrainz_id: MusicBrainz ID - :type musicbrainz_id: string - :param images: album image URIs - :type images: list of strings - """ - - #: The album URI. Read-only. - uri = Identifier() - - #: The album name. Read-only. - name = String() - - #: A set of album artists. Read-only. - artists = Collection(type=Artist, container=frozenset) - - #: The number of tracks in the album. Read-only. - num_tracks = Integer(min=0) - - #: The number of discs in the album. Read-only. - num_discs = Integer(min=0) - - #: The album release date. Read-only. - date = String() # TODO: add date type - - #: The MusicBrainz ID of the album. Read-only. - musicbrainz_id = Identifier() - - #: The album image URIs. Read-only. - images = Collection(type=basestring, container=frozenset) - # XXX If we want to keep the order of images we shouldn't use frozenset() - # as it doesn't preserve order. I'm deferring this issue until we got - # actual usage of this field with more than one image. - - -class Track(ImmutableObject): - - """ - :param uri: track URI - :type uri: string - :param name: track name - :type name: string - :param artists: track artists - :type artists: list of :class:`Artist` - :param album: track album - :type album: :class:`Album` - :param composers: track composers - :type composers: string - :param performers: track performers - :type performers: string - :param genre: track genre - :type genre: string - :param track_no: track number in album - :type track_no: integer or :class:`None` if unknown - :param disc_no: disc number in album - :type disc_no: integer or :class:`None` if unknown - :param date: track release date (YYYY or YYYY-MM-DD) - :type date: string - :param length: track length in milliseconds - :type length: integer or :class:`None` if there is no duration - :param bitrate: bitrate in kbit/s - :type bitrate: integer - :param comment: track comment - :type comment: string - :param musicbrainz_id: MusicBrainz ID - :type musicbrainz_id: string - :param last_modified: Represents last modification time - :type last_modified: integer or :class:`None` if unknown - """ - - #: The track URI. Read-only. - uri = Identifier() - - #: The track name. Read-only. - name = String() - - #: A set of track artists. Read-only. - artists = Collection(type=Artist, container=frozenset) - - #: The track :class:`Album`. Read-only. - album = Field(type=Album) - - #: A set of track composers. Read-only. - composers = Collection(type=Artist, container=frozenset) - - #: A set of track performers`. Read-only. - performers = Collection(type=Artist, container=frozenset) - - #: The track genre. Read-only. - genre = String() - - #: The track number in the album. Read-only. - track_no = Integer(min=0) - - #: The disc number in the album. Read-only. - disc_no = Integer(min=0) - - #: The track release date. Read-only. - date = String() # TODO: add date type - - #: The track length in milliseconds. Read-only. - length = Integer(min=0) - - #: The track's bitrate in kbit/s. Read-only. - bitrate = Integer(min=0) - - #: The track comment. Read-only. - comment = String() - - #: The MusicBrainz ID of the track. Read-only. - musicbrainz_id = Identifier() - - #: Integer representing when the track was last modified. Exact meaning - #: depends on source of track. For local files this is the modification - #: time in milliseconds since Unix epoch. For other backends it could be an - #: equivalent timestamp or simply a version counter. - last_modified = Integer(min=0) - - -class TlTrack(ImmutableObject): - - """ - A tracklist track. Wraps a regular track and it's tracklist ID. - - The use of :class:`TlTrack` allows the same track to appear multiple times - in the tracklist. - - This class also accepts it's parameters as positional arguments. Both - arguments must be provided, and they must appear in the order they are - listed here. - - This class also supports iteration, so your extract its values like this:: - - (tlid, track) = tl_track - - :param tlid: tracklist ID - :type tlid: int - :param track: the track - :type track: :class:`Track` - """ - - #: The tracklist ID. Read-only. - tlid = Integer(min=0) - - #: The track. Read-only. - track = Field(type=Track) - - def __init__(self, *args, **kwargs): - if len(args) == 2 and len(kwargs) == 0: - kwargs['tlid'] = args[0] - kwargs['track'] = args[1] - args = [] - super(TlTrack, self).__init__(*args, **kwargs) - - def __iter__(self): - return iter([self.tlid, self.track]) - - -class Playlist(ImmutableObject): - - """ - :param uri: playlist URI - :type uri: string - :param name: playlist name - :type name: string - :param tracks: playlist's tracks - :type tracks: list of :class:`Track` elements - :param last_modified: - playlist's modification time in milliseconds since Unix epoch - :type last_modified: int - """ - - #: The playlist URI. Read-only. - uri = Identifier() - - #: The playlist name. Read-only. - name = String() - - #: The playlist's tracks. Read-only. - tracks = Collection(type=Track, container=tuple) - - #: The playlist modification time in milliseconds since Unix epoch. - #: Read-only. - #: - #: Integer, or :class:`None` if unknown. - last_modified = Integer(min=0) - - # TODO: def insert(self, pos, track): ... ? - - @property - def length(self): - """The number of tracks in the playlist. Read-only.""" - return len(self.tracks) - - -class SearchResult(ImmutableObject): - - """ - :param uri: search result URI - :type uri: string - :param tracks: matching tracks - :type tracks: list of :class:`Track` elements - :param artists: matching artists - :type artists: list of :class:`Artist` elements - :param albums: matching albums - :type albums: list of :class:`Album` elements - """ - - # The search result URI. Read-only. - uri = Identifier() - - # The tracks matching the search query. Read-only. - tracks = Collection(type=Track, container=tuple) - - # The artists matching the search query. Read-only. - artists = Collection(type=Artist, container=tuple) - - # The albums matching the search query. Read-only. - albums = Collection(type=Album, container=tuple) diff --git a/mopidy/models/__init__.py b/mopidy/models/__init__.py new file mode 100644 index 00000000..0cdfb37f --- /dev/null +++ b/mopidy/models/__init__.py @@ -0,0 +1,352 @@ +from __future__ import absolute_import, unicode_literals + +from mopidy.models import fields +from mopidy.models.immutable import ImmutableObject + +# TODO: remove the following "exports" once users have been migrated +from mopidy.models.serialize import model_json_decoder, ModelJSONEncoder # noqa + + +class Ref(ImmutableObject): + + """ + Model to represent URI references with a human friendly name and type + attached. This is intended for use a lightweight object "free" of metadata + that can be passed around instead of using full blown models. + + :param uri: object URI + :type uri: string + :param name: object name + :type name: string + :param type: object type + :type type: string + """ + + #: The object URI. Read-only. + uri = fields.Identifier() + + #: The object name. Read-only. + name = fields.String() + + #: Constant used for comparison with the :attr:`type` field. + ALBUM = 'album' + + #: Constant used for comparison with the :attr:`type` field. + ARTIST = 'artist' + + #: Constant used for comparison with the :attr:`type` field. + DIRECTORY = 'directory' + + #: Constant used for comparison with the :attr:`type` field. + PLAYLIST = 'playlist' + + #: Constant used for comparison with the :attr:`type` field. + TRACK = 'track' + + #: The object type, e.g. "artist", "album", "track", "playlist", + #: "directory". Read-only. + type = fields.Field(choices=(ALBUM, ARTIST, DIRECTORY, PLAYLIST, TRACK)) + + @classmethod + def album(cls, **kwargs): + """Create a :class:`Ref` with ``type`` :attr:`ALBUM`.""" + kwargs['type'] = Ref.ALBUM + return cls(**kwargs) + + @classmethod + def artist(cls, **kwargs): + """Create a :class:`Ref` with ``type`` :attr:`ARTIST`.""" + kwargs['type'] = Ref.ARTIST + return cls(**kwargs) + + @classmethod + def directory(cls, **kwargs): + """Create a :class:`Ref` with ``type`` :attr:`DIRECTORY`.""" + kwargs['type'] = Ref.DIRECTORY + return cls(**kwargs) + + @classmethod + def playlist(cls, **kwargs): + """Create a :class:`Ref` with ``type`` :attr:`PLAYLIST`.""" + kwargs['type'] = Ref.PLAYLIST + return cls(**kwargs) + + @classmethod + def track(cls, **kwargs): + """Create a :class:`Ref` with ``type`` :attr:`TRACK`.""" + kwargs['type'] = Ref.TRACK + return cls(**kwargs) + + +class Image(ImmutableObject): + + """ + :param string uri: URI of the image + :param int width: Optional width of image or :class:`None` + :param int height: Optional height of image or :class:`None` + """ + + #: The image URI. Read-only. + uri = fields.Identifier() + + #: Optional width of the image or :class:`None`. Read-only. + width = fields.Integer(min=0) + + #: Optional height of the image or :class:`None`. Read-only. + height = fields.Integer(min=0) + + +class Artist(ImmutableObject): + + """ + :param uri: artist URI + :type uri: string + :param name: artist name + :type name: string + :param musicbrainz_id: MusicBrainz ID + :type musicbrainz_id: string + """ + + #: The artist URI. Read-only. + uri = fields.Identifier() + + #: The artist name. Read-only. + name = fields.String() + + #: The MusicBrainz ID of the artist. Read-only. + musicbrainz_id = fields.Identifier() + + +class Album(ImmutableObject): + + """ + :param uri: album URI + :type uri: string + :param name: album name + :type name: string + :param artists: album artists + :type artists: list of :class:`Artist` + :param num_tracks: number of tracks in album + :type num_tracks: integer or :class:`None` if unknown + :param num_discs: number of discs in album + :type num_discs: integer or :class:`None` if unknown + :param date: album release date (YYYY or YYYY-MM-DD) + :type date: string + :param musicbrainz_id: MusicBrainz ID + :type musicbrainz_id: string + :param images: album image URIs + :type images: list of strings + """ + + #: The album URI. Read-only. + uri = fields.Identifier() + + #: The album name. Read-only. + name = fields.String() + + #: A set of album artists. Read-only. + artists = fields.Collection(type=Artist, container=frozenset) + + #: The number of tracks in the album. Read-only. + num_tracks = fields.Integer(min=0) + + #: The number of discs in the album. Read-only. + num_discs = fields.Integer(min=0) + + #: The album release date. Read-only. + date = fields.String() # TODO: add date type + + #: The MusicBrainz ID of the album. Read-only. + musicbrainz_id = fields.Identifier() + + #: The album image URIs. Read-only. + images = fields.Collection(type=basestring, container=frozenset) + # XXX If we want to keep the order of images we shouldn't use frozenset() + # as it doesn't preserve order. I'm deferring this issue until we got + # actual usage of this field with more than one image. + + +class Track(ImmutableObject): + + """ + :param uri: track URI + :type uri: string + :param name: track name + :type name: string + :param artists: track artists + :type artists: list of :class:`Artist` + :param album: track album + :type album: :class:`Album` + :param composers: track composers + :type composers: string + :param performers: track performers + :type performers: string + :param genre: track genre + :type genre: string + :param track_no: track number in album + :type track_no: integer or :class:`None` if unknown + :param disc_no: disc number in album + :type disc_no: integer or :class:`None` if unknown + :param date: track release date (YYYY or YYYY-MM-DD) + :type date: string + :param length: track length in milliseconds + :type length: integer or :class:`None` if there is no duration + :param bitrate: bitrate in kbit/s + :type bitrate: integer + :param comment: track comment + :type comment: string + :param musicbrainz_id: MusicBrainz ID + :type musicbrainz_id: string + :param last_modified: Represents last modification time + :type last_modified: integer or :class:`None` if unknown + """ + + #: The track URI. Read-only. + uri = fields.Identifier() + + #: The track name. Read-only. + name = fields.String() + + #: A set of track artists. Read-only. + artists = fields.Collection(type=Artist, container=frozenset) + + #: The track :class:`Album`. Read-only. + album = fields.Field(type=Album) + + #: A set of track composers. Read-only. + composers = fields.Collection(type=Artist, container=frozenset) + + #: A set of track performers`. Read-only. + performers = fields.Collection(type=Artist, container=frozenset) + + #: The track genre. Read-only. + genre = fields.String() + + #: The track number in the album. Read-only. + track_no = fields.Integer(min=0) + + #: The disc number in the album. Read-only. + disc_no = fields.Integer(min=0) + + #: The track release date. Read-only. + date = fields.String() # TODO: add date type + + #: The track length in milliseconds. Read-only. + length = fields.Integer(min=0) + + #: The track's bitrate in kbit/s. Read-only. + bitrate = fields.Integer(min=0) + + #: The track comment. Read-only. + comment = fields.String() + + #: The MusicBrainz ID of the track. Read-only. + musicbrainz_id = fields.Identifier() + + #: Integer representing when the track was last modified. Exact meaning + #: depends on source of track. For local files this is the modification + #: time in milliseconds since Unix epoch. For other backends it could be an + #: equivalent timestamp or simply a version counter. + last_modified = fields.Integer(min=0) + + +class TlTrack(ImmutableObject): + + """ + A tracklist track. Wraps a regular track and it's tracklist ID. + + The use of :class:`TlTrack` allows the same track to appear multiple times + in the tracklist. + + This class also accepts it's parameters as positional arguments. Both + arguments must be provided, and they must appear in the order they are + listed here. + + This class also supports iteration, so your extract its values like this:: + + (tlid, track) = tl_track + + :param tlid: tracklist ID + :type tlid: int + :param track: the track + :type track: :class:`Track` + """ + + #: The tracklist ID. Read-only. + tlid = fields.Integer(min=0) + + #: The track. Read-only. + track = fields.Field(type=Track) + + def __init__(self, *args, **kwargs): + if len(args) == 2 and len(kwargs) == 0: + kwargs['tlid'] = args[0] + kwargs['track'] = args[1] + args = [] + super(TlTrack, self).__init__(*args, **kwargs) + + def __iter__(self): + return iter([self.tlid, self.track]) + + +class Playlist(ImmutableObject): + + """ + :param uri: playlist URI + :type uri: string + :param name: playlist name + :type name: string + :param tracks: playlist's tracks + :type tracks: list of :class:`Track` elements + :param last_modified: + playlist's modification time in milliseconds since Unix epoch + :type last_modified: int + """ + + #: The playlist URI. Read-only. + uri = fields.Identifier() + + #: The playlist name. Read-only. + name = fields.String() + + #: The playlist's tracks. Read-only. + tracks = fields.Collection(type=Track, container=tuple) + + #: The playlist modification time in milliseconds since Unix epoch. + #: Read-only. + #: + #: Integer, or :class:`None` if unknown. + last_modified = fields.Integer(min=0) + + # TODO: def insert(self, pos, track): ... ? + + @property + def length(self): + """The number of tracks in the playlist. Read-only.""" + return len(self.tracks) + + +class SearchResult(ImmutableObject): + + """ + :param uri: search result URI + :type uri: string + :param tracks: matching tracks + :type tracks: list of :class:`Track` elements + :param artists: matching artists + :type artists: list of :class:`Artist` elements + :param albums: matching albums + :type albums: list of :class:`Album` elements + """ + + # The search result URI. Read-only. + uri = fields.Identifier() + + # The tracks matching the search query. Read-only. + tracks = fields.Collection(type=Track, container=tuple) + + # The artists matching the search query. Read-only. + artists = fields.Collection(type=Artist, container=tuple) + + # The albums matching the search query. Read-only. + albums = fields.Collection(type=Album, container=tuple) diff --git a/mopidy/models/fields.py b/mopidy/models/fields.py new file mode 100644 index 00000000..3819c1c4 --- /dev/null +++ b/mopidy/models/fields.py @@ -0,0 +1,125 @@ +from __future__ import absolute_import, unicode_literals + + +class Field(object): + + """ + Base field for use in :class:`ImmutableObject`. These fields are + responsible for type checking and other data sanitation in our models. + + For simplicity fields use the Python descriptor protocol to store the + values in the instance dictionary. Also note that fields are mutable if + the object they are attached to allow it. + + Default values will be validated with the exception of :class:`None`. + + :param default: default value for field + :param type: if set the field value must be of this type + :param choices: if set the field value must be one of these + """ + + def __init__(self, default=None, type=None, choices=None): + self._name = None # Set by ImmutableObjectMeta + self._choices = choices + self._default = default + self._type = type + + if self._default is not None: + self.validate(self._default) + + def validate(self, value): + """Validate and possibly modify the field value before assignment""" + if self._type and not isinstance(value, self._type): + raise TypeError('Expected %s to be a %s, not %r' % + (self._name, self._type, value)) + if self._choices and value not in self._choices: + raise TypeError('Expected %s to be a one of %s, not %r' % + (self._name, self._choices, value)) + return value + + def __get__(self, instance, owner): + if not instance: + return self + return getattr(instance, '_' + self._name, self._default) + + def __set__(self, instance, value): + if value is not None: + value = self.validate(value) + + if value is None or value == self._default: + self.__delete__(instance) + else: + setattr(instance, '_' + self._name, value) + + def __delete__(self, instance): + if hasattr(instance, '_' + self._name): + delattr(instance, '_' + self._name) + + +class String(Field): + + """ + Specialized :class:`Field` which is wired up for bytes and unicode. + + :param default: default value for field + """ + + def __init__(self, default=None): + # TODO: normalize to unicode? + # TODO: only allow unicode? + # TODO: disallow empty strings? + super(String, self).__init__(type=basestring, default=default) + + +class Identifier(String): + def validate(self, value): + return intern(str(super(Identifier, self).validate(value))) + + +class Integer(Field): + + """ + :class:`Field` for storing integer numbers. + + :param default: default value for field + :param min: field value must be larger or equal to this value when set + :param max: field value must be smaller or equal to this value when set + """ + + def __init__(self, default=None, min=None, max=None): + self._min = min + self._max = max + super(Integer, self).__init__(type=(int, long), default=default) + + def validate(self, value): + value = super(Integer, self).validate(value) + if self._min is not None and value < self._min: + raise ValueError('Expected %s to be at least %d, not %d' % + (self._name, self._min, value)) + if self._max is not None and value > self._max: + raise ValueError('Expected %s to be at most %d, not %d' % + (self._name, self._max, value)) + return value + + +class Collection(Field): + + """ + :class:`Field` for storing collections of a given type. + + :param type: all items stored in the collection must be of this type + :param container: the type to store the items in + """ + + def __init__(self, type, container=tuple): + super(Collection, self).__init__(type=type, default=container()) + + def validate(self, value): + if isinstance(value, basestring): + raise TypeError('Expected %s to be a collection of %s, not %r' + % (self._name, self._type.__name__, value)) + for v in value: + if not isinstance(v, self._type): + raise TypeError('Expected %s to be a collection of %s, not %r' + % (self._name, self._type.__name__, value)) + return self._default.__class__(value) or None diff --git a/mopidy/models/immutable.py b/mopidy/models/immutable.py new file mode 100644 index 00000000..2b7bfa5b --- /dev/null +++ b/mopidy/models/immutable.py @@ -0,0 +1,160 @@ +from __future__ import absolute_import, unicode_literals + +import copy +import inspect +import itertools +import weakref + +from mopidy.models.fields import Field +from mopidy.utils import deprecation + + +class ImmutableObjectMeta(type): + + """Helper to automatically assign field names to descriptors.""" + + def __new__(cls, name, bases, attrs): + fields = {} + for key, value in attrs.items(): + if isinstance(value, Field): + fields[key] = '_' + key + value._name = key + + attrs['_fields'] = fields + attrs['_instances'] = weakref.WeakValueDictionary() + attrs['__slots__'] = ['_hash'] + fields.values() + + for ancestor in [b for base in bases for b in inspect.getmro(base)]: + if '__weakref__' in getattr(ancestor, '__slots__', []): + break + else: + attrs['__slots__'].append('__weakref__') + + return super(ImmutableObjectMeta, cls).__new__(cls, name, bases, attrs) + + def __call__(cls, *args, **kwargs): # noqa: N805 + instance = super(ImmutableObjectMeta, cls).__call__(*args, **kwargs) + return cls._instances.setdefault(weakref.ref(instance), instance) + + +class ImmutableObject(object): + + """ + Superclass for immutable objects whose fields can only be modified via the + constructor. Fields should be :class:`Field` instances to ensure type + safety in our models. + + Note that since these models can not be changed, we heavily memoize them + to save memory. So constructing a class with the same arguments twice will + give you the same instance twice. + + :param kwargs: kwargs to set as fields on the object + :type kwargs: any + """ + + __metaclass__ = ImmutableObjectMeta + + def __init__(self, *args, **kwargs): + for key, value in kwargs.items(): + if key not in self._fields: + raise TypeError( + '__init__() got an unexpected keyword argument "%s"' % + key) + super(ImmutableObject, self).__setattr__(key, value) + + def __setattr__(self, name, value): + if name in self.__slots__: + return super(ImmutableObject, self).__setattr__(name, value) + raise AttributeError('Object is immutable.') + + def __delattr__(self, name): + if name in self.__slots__: + return super(ImmutableObject, self).__delattr__(name) + raise AttributeError('Object is immutable.') + + def _items(self): + for field, key in self._fields.items(): + if hasattr(self, key): + yield field, getattr(self, key) + + def __repr__(self): + kwarg_pairs = [] + for key, value in sorted(self._items()): + if isinstance(value, (frozenset, tuple)): + if not value: + continue + value = list(value) + kwarg_pairs.append('%s=%s' % (key, repr(value))) + return '%(classname)s(%(kwargs)s)' % { + 'classname': self.__class__.__name__, + 'kwargs': ', '.join(kwarg_pairs), + } + + def __hash__(self): + if not hasattr(self, '_hash'): + hash_sum = 0 + for key, value in self._items(): + hash_sum += hash(key) + hash(value) + super(ImmutableObject, self).__setattr__('_hash', hash_sum) + return self._hash + + def __eq__(self, other): + if not isinstance(other, self.__class__): + return False + return all(a == b for a, b in itertools.izip_longest( + self._items(), other._items(), fillvalue=object())) + + def __ne__(self, other): + return not self.__eq__(other) + + def copy(self, **values): + """ + .. deprecated:: 1.1 + Use :meth:`replace` instead. Note that we no longer return copies. + """ + deprecation.warn('model.immutable.copy') + return self.replace(**values) + + def replace(self, **kwargs): + """ + Replace the fields in the model and return a new instance + + Examples:: + + # Returns a track with a new name + Track(name='foo').replace(name='bar') + # Return an album with a new number of tracks + Album(num_tracks=2).replace(num_tracks=5) + + Note that internally we memoize heavily to keep memory usage down given + our overly repetitive data structures. So you might get an existing + instance if it contains the same values. + + :param kwargs: kwargs to set as fields on the object + :type kwargs: any + :rtype: instance of the model with replaced fields + """ + if not kwargs: + return self + other = copy.copy(self) + for key, value in kwargs.items(): + if key not in self._fields: + raise TypeError( + 'copy() got an unexpected keyword argument "%s"' % key) + super(ImmutableObject, other).__setattr__(key, value) + super(ImmutableObject, other).__delattr__('_hash') + return self._instances.setdefault(weakref.ref(other), other) + + def serialize(self): + data = {} + data['__model__'] = self.__class__.__name__ + for key, value in self._items(): + if isinstance(value, (set, frozenset, list, tuple)): + value = [ + v.serialize() if isinstance(v, ImmutableObject) else v + for v in value] + elif isinstance(value, ImmutableObject): + value = value.serialize() + if not (isinstance(value, list) and len(value) == 0): + data[key] = value + return data diff --git a/mopidy/models/serialize.py b/mopidy/models/serialize.py new file mode 100644 index 00000000..0f55c659 --- /dev/null +++ b/mopidy/models/serialize.py @@ -0,0 +1,46 @@ +from __future__ import absolute_import, unicode_literals + +import json + +from mopidy.models.immutable import ImmutableObject + + +class ModelJSONEncoder(json.JSONEncoder): + + """ + Automatically serialize Mopidy models to JSON. + + Usage:: + + >>> import json + >>> json.dumps({'a_track': Track(name='name')}, cls=ModelJSONEncoder) + '{"a_track": {"__model__": "Track", "name": "name"}}' + + """ + + def default(self, obj): + if isinstance(obj, ImmutableObject): + return obj.serialize() + return json.JSONEncoder.default(self, obj) + + +def model_json_decoder(dct): + """ + Automatically deserialize Mopidy models from JSON. + + Usage:: + + >>> import json + >>> json.loads( + ... '{"a_track": {"__model__": "Track", "name": "name"}}', + ... object_hook=model_json_decoder) + {u'a_track': Track(artists=[], name=u'name')} + + """ + if '__model__' in dct: + # TODO: move models to a global constant once we split this module + models = {c.__name__: c for c in ImmutableObject.__subclasses__()} + model_name = dct.pop('__model__') + if model_name in models: + return models[model_name](**dct) + return dct diff --git a/tests/models/test_fields.py b/tests/models/test_fields.py index 1bf46b7f..6ef10f18 100644 --- a/tests/models/test_fields.py +++ b/tests/models/test_fields.py @@ -2,7 +2,8 @@ from __future__ import absolute_import, unicode_literals import unittest -from mopidy.models import * # noqa: F403 +from mopidy.models.fields import * # noqa: F403 +from mopidy.models.immutable import ImmutableObjectMeta def create_instance(field): From 1d5abe44a6355150d0a5c02ec97f07346ba0ddc9 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sat, 25 Apr 2015 00:15:03 +0200 Subject: [PATCH 2/3] models: Add Uri and Date field "placeholders" --- mopidy/models/__init__.py | 18 +++++++++--------- mopidy/models/fields.py | 8 ++++++++ 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/mopidy/models/__init__.py b/mopidy/models/__init__.py index 0cdfb37f..171d9df2 100644 --- a/mopidy/models/__init__.py +++ b/mopidy/models/__init__.py @@ -23,7 +23,7 @@ class Ref(ImmutableObject): """ #: The object URI. Read-only. - uri = fields.Identifier() + uri = fields.Uri() #: The object name. Read-only. name = fields.String() @@ -87,7 +87,7 @@ class Image(ImmutableObject): """ #: The image URI. Read-only. - uri = fields.Identifier() + uri = fields.Uri() #: Optional width of the image or :class:`None`. Read-only. width = fields.Integer(min=0) @@ -108,7 +108,7 @@ class Artist(ImmutableObject): """ #: The artist URI. Read-only. - uri = fields.Identifier() + uri = fields.Uri() #: The artist name. Read-only. name = fields.String() @@ -139,7 +139,7 @@ class Album(ImmutableObject): """ #: The album URI. Read-only. - uri = fields.Identifier() + uri = fields.Uri() #: The album name. Read-only. name = fields.String() @@ -154,7 +154,7 @@ class Album(ImmutableObject): num_discs = fields.Integer(min=0) #: The album release date. Read-only. - date = fields.String() # TODO: add date type + date = fields.Date() #: The MusicBrainz ID of the album. Read-only. musicbrainz_id = fields.Identifier() @@ -202,7 +202,7 @@ class Track(ImmutableObject): """ #: The track URI. Read-only. - uri = fields.Identifier() + uri = fields.Uri() #: The track name. Read-only. name = fields.String() @@ -229,7 +229,7 @@ class Track(ImmutableObject): disc_no = fields.Integer(min=0) #: The track release date. Read-only. - date = fields.String() # TODO: add date type + date = fields.Date() #: The track length in milliseconds. Read-only. length = fields.Integer(min=0) @@ -304,7 +304,7 @@ class Playlist(ImmutableObject): """ #: The playlist URI. Read-only. - uri = fields.Identifier() + uri = fields.Uri() #: The playlist name. Read-only. name = fields.String() @@ -340,7 +340,7 @@ class SearchResult(ImmutableObject): """ # The search result URI. Read-only. - uri = fields.Identifier() + uri = fields.Uri() # The tracks matching the search query. Read-only. tracks = fields.Collection(type=Track, container=tuple) diff --git a/mopidy/models/fields.py b/mopidy/models/fields.py index 3819c1c4..250e4758 100644 --- a/mopidy/models/fields.py +++ b/mopidy/models/fields.py @@ -71,11 +71,19 @@ class String(Field): super(String, self).__init__(type=basestring, default=default) +class Date(String): + pass # TODO: make this check for YYYY-MM-DD, YYYY-MM, YYYY using stftime. + + class Identifier(String): def validate(self, value): return intern(str(super(Identifier, self).validate(value))) +class Uri(Identifier): + pass # TODO: validate URIs? + + class Integer(Field): """ From 5f420a1bffec58b4a8a78bc3a47487b681a5417b Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Sun, 26 Apr 2015 23:31:56 +0200 Subject: [PATCH 3/3] review: Address review comments for models split --- mopidy/models/__init__.py | 20 +++++++++++--------- mopidy/models/fields.py | 6 +++--- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/mopidy/models/__init__.py b/mopidy/models/__init__.py index 171d9df2..7cf8cc77 100644 --- a/mopidy/models/__init__.py +++ b/mopidy/models/__init__.py @@ -2,9 +2,11 @@ from __future__ import absolute_import, unicode_literals from mopidy.models import fields from mopidy.models.immutable import ImmutableObject +from mopidy.models.serialize import ModelJSONEncoder, model_json_decoder -# TODO: remove the following "exports" once users have been migrated -from mopidy.models.serialize import model_json_decoder, ModelJSONEncoder # noqa +__all__ = [ + 'ImmutableObject', 'Ref', 'Image', 'Artist', 'Album', 'track', 'TlTrack', + 'Playlist', 'SearchResult', 'model_json_decoder', 'ModelJSONEncoder'] class Ref(ImmutableObject): @@ -23,7 +25,7 @@ class Ref(ImmutableObject): """ #: The object URI. Read-only. - uri = fields.Uri() + uri = fields.URI() #: The object name. Read-only. name = fields.String() @@ -87,7 +89,7 @@ class Image(ImmutableObject): """ #: The image URI. Read-only. - uri = fields.Uri() + uri = fields.URI() #: Optional width of the image or :class:`None`. Read-only. width = fields.Integer(min=0) @@ -108,7 +110,7 @@ class Artist(ImmutableObject): """ #: The artist URI. Read-only. - uri = fields.Uri() + uri = fields.URI() #: The artist name. Read-only. name = fields.String() @@ -139,7 +141,7 @@ class Album(ImmutableObject): """ #: The album URI. Read-only. - uri = fields.Uri() + uri = fields.URI() #: The album name. Read-only. name = fields.String() @@ -202,7 +204,7 @@ class Track(ImmutableObject): """ #: The track URI. Read-only. - uri = fields.Uri() + uri = fields.URI() #: The track name. Read-only. name = fields.String() @@ -304,7 +306,7 @@ class Playlist(ImmutableObject): """ #: The playlist URI. Read-only. - uri = fields.Uri() + uri = fields.URI() #: The playlist name. Read-only. name = fields.String() @@ -340,7 +342,7 @@ class SearchResult(ImmutableObject): """ # The search result URI. Read-only. - uri = fields.Uri() + uri = fields.URI() # The tracks matching the search query. Read-only. tracks = fields.Collection(type=Track, container=tuple) diff --git a/mopidy/models/fields.py b/mopidy/models/fields.py index 250e4758..23154df5 100644 --- a/mopidy/models/fields.py +++ b/mopidy/models/fields.py @@ -72,7 +72,7 @@ class String(Field): class Date(String): - pass # TODO: make this check for YYYY-MM-DD, YYYY-MM, YYYY using stftime. + pass # TODO: make this check for YYYY-MM-DD, YYYY-MM, YYYY using strptime. class Identifier(String): @@ -80,7 +80,7 @@ class Identifier(String): return intern(str(super(Identifier, self).validate(value))) -class Uri(Identifier): +class URI(Identifier): pass # TODO: validate URIs? @@ -92,7 +92,7 @@ class Integer(Field): :param default: default value for field :param min: field value must be larger or equal to this value when set :param max: field value must be smaller or equal to this value when set - """ + """ def __init__(self, default=None, min=None, max=None): self._min = min