models: Split models into smaller modules
This commit is contained in:
parent
b3ea425fd0
commit
7e59a5aecb
672
mopidy/models.py
672
mopidy/models.py
@ -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)
|
|
||||||
352
mopidy/models/__init__.py
Normal file
352
mopidy/models/__init__.py
Normal file
@ -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)
|
||||||
125
mopidy/models/fields.py
Normal file
125
mopidy/models/fields.py
Normal file
@ -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
|
||||||
160
mopidy/models/immutable.py
Normal file
160
mopidy/models/immutable.py
Normal file
@ -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
|
||||||
46
mopidy/models/serialize.py
Normal file
46
mopidy/models/serialize.py
Normal file
@ -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
|
||||||
@ -2,7 +2,8 @@ from __future__ import absolute_import, unicode_literals
|
|||||||
|
|
||||||
import unittest
|
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):
|
def create_instance(field):
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user