Python3 Migrate

This commit is contained in:
MariuszC
2020-01-18 20:01:00 +01:00
parent ea05af2d15
commit 6cd7e0fe44
691 changed files with 201846 additions and 598 deletions

View File

@@ -0,0 +1,361 @@
from mopidy.models import fields
from mopidy.models.immutable import ImmutableObject, ValidatedImmutableObject
from mopidy.models.serialize import ModelJSONEncoder, model_json_decoder
__all__ = [
"ImmutableObject",
"Ref",
"Image",
"Artist",
"Album",
"Track",
"TlTrack",
"Playlist",
"SearchResult",
"model_json_decoder",
"ModelJSONEncoder",
"ValidatedImmutableObject",
]
class Ref(ValidatedImmutableObject):
"""
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.URI()
#: The object name. Read-only.
name = fields.String()
#: The object type, e.g. "artist", "album", "track", "playlist",
#: "directory". Read-only.
type = fields.Identifier() # TODO: consider locking this down.
# type = fields.Field(choices=(ALBUM, ARTIST, DIRECTORY, PLAYLIST, TRACK))
#: 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"
@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(ValidatedImmutableObject):
"""
: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.URI()
#: 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(ValidatedImmutableObject):
"""
:param uri: artist URI
:type uri: string
:param name: artist name
:type name: string
:param sortname: artist name for sorting
:type sortname: string
:param musicbrainz_id: MusicBrainz ID
:type musicbrainz_id: string
"""
#: The artist URI. Read-only.
uri = fields.URI()
#: The artist name. Read-only.
name = fields.String()
#: Artist name for better sorting, e.g. with articles stripped
sortname = fields.String()
#: The MusicBrainz ID of the artist. Read-only.
musicbrainz_id = fields.Identifier()
class Album(ValidatedImmutableObject):
"""
: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
"""
#: The album URI. Read-only.
uri = fields.URI()
#: 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.Date()
#: The MusicBrainz ID of the album. Read-only.
musicbrainz_id = fields.Identifier()
class Track(ValidatedImmutableObject):
"""
: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: list of :class:`Artist`
:param performers: track performers
:type performers: list of :class:`Artist`
: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.URI()
#: 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.Date()
#: 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(ValidatedImmutableObject):
"""
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().__init__(*args, **kwargs)
def __iter__(self):
return iter([self.tlid, self.track])
class Playlist(ValidatedImmutableObject):
"""
: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.URI()
#: 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(ValidatedImmutableObject):
"""
: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.URI()
#: 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)

View File

@@ -0,0 +1,179 @@
import sys
class Field:
"""
Base field for use in
:class:`~mopidy.models.immutable.ValidatedImmutableObject`. 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 ValidatedImmutableObjectMeta
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(
f"Expected {self._name} to be a {self._type}, not {value!r}"
)
if self._choices and value not in self._choices:
raise TypeError(
f"Expected {self._name} to be a one of {self._choices}, not {value!r}"
)
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().__init__(type=str, default=default)
class Date(String):
"""
:class:`Field` for storing ISO 8601 dates as a string.
Supported formats are ``YYYY-MM-DD``, ``YYYY-MM`` and ``YYYY``, currently
not validated.
:param default: default value for field
"""
pass # TODO: make this check for YYYY-MM-DD, YYYY-MM, YYYY using strptime.
class Identifier(String):
"""
:class:`Field` for storing values such as GUIDs or other identifiers.
Values will be interned.
:param default: default value for field
"""
def validate(self, value):
value = super().validate(value)
if isinstance(value, bytes):
value = value.decode()
return sys.intern(value)
class URI(Identifier):
"""
:class:`Field` for storing URIs
Values will be interned, currently not validated.
:param default: default value for field
"""
pass # TODO: validate URIs?
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().__init__(type=int, default=default)
def validate(self, value):
value = super().validate(value)
if self._min is not None and value < self._min:
raise ValueError(
f"Expected {self._name} to be at least {self._min}, not {value:d}"
)
if self._max is not None and value > self._max:
raise ValueError(
f"Expected {self._name} to be at most {self._max}, not {value:d}"
)
return value
class Boolean(Field):
"""
:class:`Field` for storing boolean values
:param default: default value for field
"""
def __init__(self, default=None):
super().__init__(type=bool, default=default)
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().__init__(type=type, default=container())
def validate(self, value):
if isinstance(value, str):
raise TypeError(
f"Expected {self._name} to be a collection of "
f"{self._type.__name__}, not {value!r}"
)
for v in value:
if not isinstance(v, self._type):
raise TypeError(
f"Expected {self._name} to be a collection of "
f"{self._type.__name__}, not {value!r}"
)
return self._default.__class__(value) or None

View File

@@ -0,0 +1,219 @@
import copy
import itertools
import weakref
from mopidy.models.fields import Field
# Registered models for automatic deserialization
_models = {}
class ImmutableObject:
"""
Superclass for immutable objects whose fields can only be modified via the
constructor.
This version of this class has been retained to avoid breaking any clients
relying on it's behavior. Internally in Mopidy we now use
:class:`ValidatedImmutableObject` for type safety and it's much smaller
memory footprint.
:param kwargs: kwargs to set as fields on the object
:type kwargs: any
"""
# Any sub-classes that don't set slots won't be effected by the base using
# slots as they will still get an instance dict.
__slots__ = ["__weakref__"]
def __init__(self, *args, **kwargs):
for key, value in kwargs.items():
if not self._is_valid_field(key):
raise TypeError(
f"__init__() got an unexpected keyword argument {key!r}"
)
self._set_field(key, value)
def __setattr__(self, name, value):
if name.startswith("_"):
object.__setattr__(self, name, value)
else:
raise AttributeError("Object is immutable.")
def __delattr__(self, name):
if name.startswith("_"):
object.__delattr__(self, name)
else:
raise AttributeError("Object is immutable.")
def _is_valid_field(self, name):
return hasattr(self, name) and not callable(getattr(self, name))
def _set_field(self, name, value):
if value == getattr(self.__class__, name):
self.__dict__.pop(name, None)
else:
self.__dict__[name] = value
def _items(self):
return self.__dict__.items()
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(f"{key}={value!r}")
return f"{self.__class__.__name__}({', '.join(kwarg_pairs)})"
def __hash__(self):
hash_sum = 0
for key, value in self._items():
hash_sum += hash(key) + hash(value)
return hash_sum
def __eq__(self, other):
if not isinstance(other, self.__class__):
return False
return all(
a == b
for a, b in itertools.zip_longest(
self._items(), other._items(), fillvalue=object()
)
)
def __ne__(self, other):
return not self.__eq__(other)
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)
:param kwargs: kwargs to set as fields on the object
:type kwargs: any
:rtype: instance of the model with replaced fields
"""
other = copy.copy(self)
for key, value in kwargs.items():
if not self._is_valid_field(key):
raise TypeError(
f"replace() got an unexpected keyword argument {key!r}"
)
other._set_field(key, value)
return 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 _ValidatedImmutableObjectMeta(type):
"""Helper that initializes fields, slots and memoizes instance creation."""
def __new__(cls, name, bases, attrs):
fields = {}
for base in bases: # Copy parent fields over to our state
fields.update(getattr(base, "_fields", {}))
for key, value in attrs.items(): # Add our own fields
if isinstance(value, Field):
fields[key] = "_" + key
value._name = key
attrs["_fields"] = fields
attrs["_instances"] = weakref.WeakValueDictionary()
attrs["__slots__"] = list(attrs.get("__slots__", [])) + list(
fields.values()
)
clsc = super().__new__(cls, name, bases, attrs)
if clsc.__name__ != "ValidatedImmutableObject":
_models[clsc.__name__] = clsc
return clsc
def __call__(cls, *args, **kwargs): # noqa: N805
instance = super().__call__(*args, **kwargs)
return cls._instances.setdefault(weakref.ref(instance), instance)
class ValidatedImmutableObject(
ImmutableObject, metaclass=_ValidatedImmutableObjectMeta
):
"""
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.
"""
__slots__ = ["_hash"]
def __hash__(self):
if not hasattr(self, "_hash"):
hash_sum = super().__hash__()
object.__setattr__(self, "_hash", hash_sum)
return self._hash
def _is_valid_field(self, name):
return name in self._fields
def _set_field(self, name, value):
object.__setattr__(self, name, value)
def _items(self):
for field, key in self._fields.items():
if hasattr(self, key):
yield field, getattr(self, key)
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 = super().replace(**kwargs)
if hasattr(self, "_hash"):
object.__delattr__(other, "_hash")
return self._instances.setdefault(weakref.ref(other), other)

View File

@@ -0,0 +1,43 @@
import json
from mopidy.models import immutable
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, immutable.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:
model_name = dct.pop("__model__")
if model_name in immutable._models:
cls = immutable._models[model_name]
return cls(**dct)
return dct