From 98f02158f922e7b8996b869d4893a07492482d31 Mon Sep 17 00:00:00 2001 From: Shaowen Yin Date: Sun, 25 Aug 2024 18:12:03 +0800 Subject: [PATCH] serde: add deserializer and refactor mode serializer (#869) --- feeluown/library/models.py | 31 ++++- feeluown/serializers/__init__.py | 44 +++++-- feeluown/serializers/base.py | 49 +++++++- feeluown/serializers/model_helpers.py | 120 ------------------- feeluown/serializers/objs.py | 127 ++++++++++++++++++-- feeluown/serializers/plain.py | 151 ++++++++++++++---------- feeluown/serializers/python.py | 125 ++++++++------------ feeluown/webserver/jsonrpc_.py | 21 +++- tests/serializers/test_deserializers.py | 56 +++++++++ tests/serializers/test_serializers.py | 29 ++++- 10 files changed, 459 insertions(+), 294 deletions(-) delete mode 100644 feeluown/serializers/model_helpers.py create mode 100644 tests/serializers/test_deserializers.py diff --git a/feeluown/library/models.py b/feeluown/library/models.py index deb067d209..a65bf3983b 100644 --- a/feeluown/library/models.py +++ b/feeluown/library/models.py @@ -54,7 +54,11 @@ import time from typing import List, Optional, Tuple, Any, Union -from pydantic import ConfigDict, BaseModel as _BaseModel, PrivateAttr +from pydantic import ( + ConfigDict, BaseModel as _BaseModel, PrivateAttr, + model_validator, model_serializer, +) + try: # pydantic>=2.0 from pydantic import field_validator @@ -215,6 +219,29 @@ def __getattr__(self, attr): return getattr(self, attr[:-8]) raise + @model_validator(mode='before') + def _deserialize(cls, value): + if isinstance(value, dict): + js = value + if 'provider' in js: + js['source'] = js.pop('provider', None) + js.pop('uri', None) + js.pop('__type__', None) + return js + return value + + @model_serializer(mode='wrap') + def _serialize(self, f): + from feeluown.library import reverse + + js = f(self) + js.pop('meta') + js.pop('state') + js['provider'] = js['source'] + js['uri'] = reverse(self) + js['__type__'] = f'feeluown.library.{self.__class__.__name__}' + return js + class BaseBriefModel(BaseModel): """ @@ -279,7 +306,7 @@ class SongModel(BriefSongModel, BaseNormalModel): meta: Any = ModelMeta.create(ModelType.song, is_normal=True) title: str album: Optional[TAlbum] = None - artists: List[BriefArtistModel] + artists: List[TArtist] duration: int # milliseconds # A playlist can consist of multiple songs and a song can have many children. # The differences between playlist's songs and song' children is that diff --git a/feeluown/serializers/__init__.py b/feeluown/serializers/__init__.py index c56b9c0641..eafde5a5be 100644 --- a/feeluown/serializers/__init__.py +++ b/feeluown/serializers/__init__.py @@ -1,4 +1,4 @@ -from .base import SerializerError +from .base import SerializerError, DeserializerError # format Serializer mapping, like:: # @@ -7,27 +7,40 @@ # 'plain': PlainSerializer # } _MAPPING = {} +_DE_MAPPING = {} def register_serializer(type_, serializer_cls): _MAPPING[type_] = serializer_cls -def _load_serializers(): - register_serializer('plain', PlainSerializer) - register_serializer('json', JsonSerializer) - register_serializer('python', PythonSerializer) +def register_deserializer(type_, deserializer_cls): + _DE_MAPPING[type_] = deserializer_cls -def get_serializer(format): +def get_serializer(format_): + global _MAPPING + if not _MAPPING: - _load_serializers() - if format not in _MAPPING: - raise SerializerError("Serializer for format:{} not found".format(format)) - return _MAPPING.get(format) + register_serializer('plain', PlainSerializer) + register_serializer('json', JsonSerializer) + register_serializer('python', PythonSerializer) + if format_ not in _MAPPING: + raise SerializerError(f"Serializer for format:{format_} not found") + return _MAPPING.get(format_) + + +def get_deserializer(format_: str): + global _DE_MAPPING + if not _DE_MAPPING: + register_deserializer('python', PythonDeserializer) + if format_ not in _DE_MAPPING: + raise DeserializerError(f"Deserializer for format:{format_} not found") + return _DE_MAPPING[format_] -def serialize(format, obj, **options): + +def serialize(format_, obj, **options): """serialize python object defined in feeluown package :raises SerializerError: @@ -40,12 +53,17 @@ def serialize(format, obj, **options): serialize('json', songs, indent=4, fetch=True) serialize('json', providers) """ - serializer = get_serializer(format)(**options) + serializer = get_serializer(format_)(**options) return serializer.serialize(obj) +def deserialize(format_, obj, **options): + deserializer = get_deserializer(format_)(**options) + return deserializer.deserialize(obj) + + from .base import SerializerMeta, SimpleSerializerMixin # noqa from .plain import PlainSerializer # noqa from .json_ import JsonSerializer # noqa -from .python import PythonSerializer # noqa +from .python import PythonSerializer, PythonDeserializer # noqa from .objs import * # noqa diff --git a/feeluown/serializers/base.py b/feeluown/serializers/base.py index 5447fd376c..0759942a44 100644 --- a/feeluown/serializers/base.py +++ b/feeluown/serializers/base.py @@ -12,6 +12,17 @@ class SerializerError(Exception): pass +class DeserializerError(Exception): + """ + this error will be raised when + + * Deserializer initialization failed + * Deserializer not found + * Deserializer serialization failed + """ + pass + + class Serializer: """Serializer abstract base class @@ -62,7 +73,43 @@ def get_serializer_cls(cls, model): # FIXME: remove me when model v2 has its own serializer if isinstance(model, model_cls): return serialize_cls - raise SerializerError("no serializer for {}".format(model)) + raise SerializerError(f"no serializer for {type(model)}") + + +class Deserializer: + def __init__(self, **options): + """ + Subclass should validate and parse options by themselves, here, + we list three commonly used options. + + as_line is a *format* option: + + - as_line: line format of a object (mainly designed for PlainSerializer) + + brief and fetch are *representation* options: + + - brief: a minimum human readable representation of the object. + we hope that people can *identify which object it is* through + this representation. + For example, if an object has ten attributes, this representation + may only contain three attributes. + + - fetch: if this option is specified, the attribute value + should be authoritative. + """ + self.options = copy.deepcopy(options) + + def deserialize(self, obj): + deserializer_cls = self.get_deserializer_cls(obj) + return deserializer_cls(**self.options).deserialize(obj) + + @classmethod + def get_deserializer_cls(cls, model): + for model_cls, serialize_cls in cls._mapping.items(): + # FIXME: remove me when model v2 has its own serializer + if isinstance(model, model_cls): + return serialize_cls + raise SerializerError(f"no serializer for {type(model)}") class SerializerMeta(type): diff --git a/feeluown/serializers/model_helpers.py b/feeluown/serializers/model_helpers.py deleted file mode 100644 index 96b6fa4a1d..0000000000 --- a/feeluown/serializers/model_helpers.py +++ /dev/null @@ -1,120 +0,0 @@ -from feeluown.library import AbstractProvider -from feeluown.library import ( - BaseModel, - SongModel, - ArtistModel, - AlbumModel, - PlaylistModel, - UserModel, - BriefSongModel, - BriefArtistModel, - BriefAlbumModel, - BriefPlaylistModel, - BriefUserModel, - SimpleSearchResult, -) -from feeluown.library import reverse - - -class ModelSerializerMixin: - - def _get_items(self, model): - # initialize fields that need to be serialized - # if as_line option is set, we always use fields_display - if self.opt_as_line or self.opt_brief: - modelcls = type(model) - fields = [field for field in model.__fields__ - if field not in BaseModel.__fields__] - # Include properties. - pydantic_fields = ("__values__", "fields", "__fields_set__", - "model_computed_fields", "model_extra", - "model_fields_set") - fields += [prop for prop in dir(modelcls) - if isinstance(getattr(modelcls, prop), property) - and prop not in pydantic_fields] - else: - fields = self._declared_fields - items = [("provider", model.source), - ("identifier", str(model.identifier)), - ("uri", reverse(model))] - if self.opt_fetch: - for field in fields: - items.append((field, getattr(model, field))) - else: - for field in fields: - items.append((field, getattr(model, field + '_display'))) - return items - - -class SongSerializerMixin: - class Meta: - types = (SongModel, BriefSongModel) - # since url can be too long, we put it at last - fields = ('title', 'duration', 'album', 'artists') - line_fmt = '{uri:{uri_length}}\t# {title:_18} - {artists_name:_20}' - - -class ArtistSerializerMixin: - class Meta: - types = (ArtistModel, BriefArtistModel) - fields = ('name', 'songs') - line_fmt = '{uri:{uri_length}}\t# {name:_40}' - - -class AlbumSerializerMixin: - class Meta: - types = (AlbumModel, BriefAlbumModel) - fields = ('name', 'artists', 'songs') - line_fmt = '{uri:{uri_length}}\t# {name:_18} - {artists_name:_20}' - - -class PlaylistSerializerMixin: - class Meta: - types = (PlaylistModel, BriefPlaylistModel) - fields = ('name', ) - line_fmt = '{uri:{uri_length}}\t# {name:_40}' - - -class UserSerializerMixin: - class Meta: - types = (UserModel, BriefUserModel) - fields = ('name', 'playlists') - line_fmt = '{uri:{uri_length}}\t# {name:_40}' - - -class SearchSerializerMixin: - """ - - .. note:: - - SearchModel isn't a standard model, it does not have identifier, - the uri of SearchModel instance is also not so graceful, so we handle - it as a normal object temporarily. - """ - - class Meta: - types = (SimpleSearchResult, ) - - def _get_items(self, result): - fields = ('songs', 'albums', 'artists', 'playlists',) - items = [] - for field in fields: - value = getattr(result, field) - if value: # only append if it is not empty - items.append((field, value)) - return items - - -class ProviderSerializerMixin: - class Meta: - types = (AbstractProvider, ) - - def _get_items(self, provider): - """ - :type provider: AbstractProvider - """ - return [ - ('identifier', provider.identifier), - ('uri', 'fuo://{}'.format(provider.identifier)), - ('name', provider.name), - ] diff --git a/feeluown/serializers/objs.py b/feeluown/serializers/objs.py index 864d9a879e..69ae564756 100644 --- a/feeluown/serializers/objs.py +++ b/feeluown/serializers/objs.py @@ -3,21 +3,57 @@ TODO: too much code to define serializers for an object. """ + from feeluown.app import App +from feeluown.library import AbstractProvider, SimpleSearchResult, reverse from feeluown.player import PlaybackMode, State, Metadata from . import PlainSerializer, PythonSerializer, \ SerializerMeta, SimpleSerializerMixin +from .python import ListSerializer as PythonListSerializer +from .plain import ListSerializer as PlainListSerializer + + +class SearchSerializerMixin: + """ + + .. note:: + + SearchModel isn't a standard model, it does not have identifier, + the uri of SearchModel instance is also not so graceful, so we handle + it as a normal object temporarily. + """ + + class Meta: + types = (SimpleSearchResult,) + + def _get_items(self, result): + fields = ('songs', 'albums', 'artists', 'playlists',) + items = [] + for field in fields: + value = getattr(result, field) + if value: # only append if it is not empty + items.append((field, value)) + return items -__all__ = ( - 'AppPythonSerializer', - 'AppPlainSerializer', -) +class ProviderSerializerMixin: + class Meta: + types = (AbstractProvider,) + + def _get_items(self, provider): + """ + :type provider: AbstractProvider + """ + return [ + ('identifier', provider.identifier), + ('uri', 'fuo://{}'.format(provider.identifier)), + ('name', provider.name), + ] class AppSerializerMixin: class Meta: - types = (App, ) + types = (App,) def _get_items(self, app): player = app.player @@ -33,7 +69,7 @@ def _get_items(self, app): ('state', player.state.name), ] if player.state in (State.playing, State.paused) and \ - player.current_song is not None: + player.current_song is not None: items.extend([ ('duration', player.duration), ('position', player.position), @@ -45,7 +81,7 @@ def _get_items(self, app): class DictLikeSerializerMixin: class Meta: - types = (Metadata, ) + types = (Metadata,) def _get_items(self, metadata): return [(key.value, value) for key, value in metadata.items()] @@ -67,10 +103,85 @@ class DictLikePythonSerializer(PythonSerializer, pass +class ProviderPythonSerializer(PythonSerializer, + SimpleSerializerMixin, + ProviderSerializerMixin, + metaclass=SerializerMeta): + pass + + +class SearchPythonSerializer(PythonSerializer, SearchSerializerMixin, + metaclass=SerializerMeta): + + def serialize(self, result): + list_serializer = PythonListSerializer() + json_ = {} + for field, value in self._get_items(result): + json_[field] = list_serializer.serialize(value) + return json_ + + # Plain serializers. # class AppPlainSerializer(PlainSerializer, AppSerializerMixin, SimpleSerializerMixin, metaclass=SerializerMeta): - pass + ... + + +class ProviderPlainSerializer(PlainSerializer, ProviderSerializerMixin, + metaclass=SerializerMeta): + + def __init__(self, **options): + super().__init__(**options) + self.opt_as_line = options.get('as_line', False) + self.opt_uri_length = options.get('uri_length', '') + + def serialize(self, provider): + """ + :type provider: AbstractProvider + """ + items = self._get_items(provider) + dict_ = dict(items) + uri = dict_['uri'] + name = dict_['name'] + if self.opt_as_line or self.opt_level > 0: + return '{uri:{uri_length}}\t# {name}'.format( + uri=uri, + name=name, + uri_length=self.opt_uri_length + ) + return self.serialize_items(items) + + +class SearchPlainSerializer(PlainSerializer, SearchSerializerMixin, + metaclass=SerializerMeta): + + def __init__(self, **options): + super().__init__(**options) + self.opt_uri_length = options.get('uri_length', '') + + def serialize(self, result): + items = self._get_items(result) + # when serialize SearchModel, we formatt it as one line when level > 1 + if self.opt_level >= 2: + return str(result) # I think we will never use this line format + text_list = [] + for field, value in items: + serializer = PlainListSerializer( + level=self.opt_level - 1, + fetch=False, + uri_length=self.opt_uri_length + ) + value_text = serializer.serialize(value) + text_list.append(value_text) + return '\n'.join(text_list) + + def calc_max_uri_length(self, result): + items = self._get_items(result) + uri_length = 0 + for field, value in items: + for each in value: + uri_length = max(uri_length, len(reverse(each))) + return uri_length diff --git a/feeluown/serializers/plain.py b/feeluown/serializers/plain.py index f6ecebea5c..280be781aa 100644 --- a/feeluown/serializers/plain.py +++ b/feeluown/serializers/plain.py @@ -1,16 +1,86 @@ from textwrap import indent # FIXME: maybe we should move `reverse` into serializers package -from feeluown.library import reverse from .base import Serializer, SerializerMeta, SerializerError -from .model_helpers import ModelSerializerMixin, SongSerializerMixin, \ - ArtistSerializerMixin, AlbumSerializerMixin, PlaylistSerializerMixin, \ - UserSerializerMixin, SearchSerializerMixin, ProviderSerializerMixin from ._plain_formatter import WideFormatter +from feeluown.library import ( + reverse, + BaseModel, + SongModel, + ArtistModel, + AlbumModel, + PlaylistModel, + UserModel, + BriefSongModel, + BriefArtistModel, + BriefAlbumModel, + BriefPlaylistModel, + BriefUserModel, +) + formatter = WideFormatter() fmt = formatter.format +class ModelSerializerMixin: + + def _get_items(self, model): + # initialize fields that need to be serialized + # if as_line option is set, we always use fields_display + modelcls = type(model) + fields = [field for field in model.__fields__ + if field not in BaseModel.__fields__] + # Include properties. + pydantic_fields = ("__values__", "fields", "__fields_set__", + "model_computed_fields", "model_extra", + "model_fields_set") + fields += [prop for prop in dir(modelcls) + if isinstance(getattr(modelcls, prop), property) + and prop not in pydantic_fields] + items = [("provider", model.source), + ("identifier", str(model.identifier)), + ("uri", reverse(model))] + for field in fields: + items.append((field, getattr(model, field))) + return items + + +class SongSerializerMixin: + class Meta: + types = (SongModel, BriefSongModel) + # since url can be too long, we put it at last + fields = ('title', 'duration', 'album', 'artists') + line_fmt = '{uri:{uri_length}}\t# {title:_18} - {artists_name:_20}' + + +class ArtistSerializerMixin: + class Meta: + types = (ArtistModel, BriefArtistModel) + fields = ('name', 'songs') + line_fmt = '{uri:{uri_length}}\t# {name:_40}' + + +class AlbumSerializerMixin: + class Meta: + types = (AlbumModel, BriefAlbumModel) + fields = ('name', 'artists', 'songs') + line_fmt = '{uri:{uri_length}}\t# {name:_18} - {artists_name:_20}' + + +class PlaylistSerializerMixin: + class Meta: + types = (PlaylistModel, BriefPlaylistModel) + fields = ('name',) + line_fmt = '{uri:{uri_length}}\t# {name:_40}' + + +class UserSerializerMixin: + class Meta: + types = (UserModel, BriefUserModel) + fields = ('name', 'playlists') + line_fmt = '{uri:{uri_length}}\t# {name:_40}' + + class PlainSerializer(Serializer): """PlainSerializer base class""" _mapping = {} @@ -54,7 +124,7 @@ def __init__(self, **options): options.get('brief') is False, options.get('fetch') is False]): raise SerializerError( - "as_line, brief, fetch can't be false at same time") + "as_line, brief, fetch can't be false at same time") if options.get('as_line') is True and options.get('brief') is False: raise SerializerError( "brief can't be False when as_line is True") @@ -85,17 +155,20 @@ class ListSerializer(PlainSerializer, metaclass=SerializerMeta): SearchModel is an exception. """ + class Meta: - types = (list, ) + types = (list,) def serialize(self, list_): + from .objs import SearchPlainSerializer + if not list_: return '' item0 = list_[0] serializer_cls = PlainSerializer.get_serializer_cls(item0) level = self.opt_level + 1 if issubclass(serializer_cls, ModelSerializer): - if issubclass(serializer_cls, SearchSerializer): + if issubclass(serializer_cls, SearchPlainSerializer): return self.serialize_search_result_list(list_) uri_length = max(len(reverse(item)) for item in list_) serializer = serializer_cls(fetch=False, level=level, @@ -112,8 +185,14 @@ def serialize(self, list_): return '\n'.join(text_list) def serialize_search_result_list(self, list_): - serializer = SearchSerializer(level=self.opt_level + 1, - fetch=False, brief=True, as_line=False) + from .objs import SearchPlainSerializer + + serializer = SearchPlainSerializer( + level=self.opt_level + 1, + fetch=False, + brief=True, + as_line=False + ) # calculate max uri_length max_uri_length = 0 for model in list_: @@ -166,57 +245,3 @@ def serialize(self, object): elif object is False: return 'false' return str(object) - - -class ProviderSerializer(PlainSerializer, ProviderSerializerMixin, - metaclass=SerializerMeta): - - def __init__(self, **options): - super().__init__(**options) - self.opt_as_line = options.get('as_line', False) - self.opt_uri_length = options.get('uri_length', '') - - def serialize(self, provider): - """ - :type provider: AbstractProvider - """ - items = self._get_items(provider) - dict_ = dict(items) - uri = dict_['uri'] - name = dict_['name'] - if self.opt_as_line or self.opt_level > 0: - return '{uri:{uri_length}}\t# {name}'.format( - uri=uri, - name=name, - uri_length=self.opt_uri_length - ) - return self.serialize_items(items) - - -class SearchSerializer(PlainSerializer, SearchSerializerMixin, - metaclass=SerializerMeta): - - def __init__(self, **options): - super().__init__(**options) - self.opt_uri_length = options.get('uri_length', '') - - def serialize(self, result): - items = self._get_items(result) - # when serialize SearchModel, we formatt it as one line when level > 1 - if self.opt_level >= 2: - return str(result) # I think we will never use this line format - text_list = [] - for field, value in items: - serializer = ListSerializer(level=self.opt_level - 1, fetch=False, - uri_length=self.opt_uri_length) - value_text = serializer.serialize(value) - text_list.append(value_text) - return '\n'.join(text_list) - - def calc_max_uri_length(self, result): - items = self._get_items(result) - uri_length = 0 - for field, value in items: - for each in value: - uri_length = max(uri_length, len(reverse(each))) - return uri_length diff --git a/feeluown/serializers/python.py b/feeluown/serializers/python.py index 1f7232ae73..b6bb3b0a91 100644 --- a/feeluown/serializers/python.py +++ b/feeluown/serializers/python.py @@ -1,9 +1,41 @@ -from .typename import attach_typename -from .base import Serializer, SerializerMeta, SerializerError, \ - SimpleSerializerMixin -from .model_helpers import ModelSerializerMixin, SongSerializerMixin, \ - ArtistSerializerMixin, AlbumSerializerMixin, PlaylistSerializerMixin, \ - UserSerializerMixin, SearchSerializerMixin, ProviderSerializerMixin +from feeluown.library import BaseModel + +from .typename import attach_typename, get_type_by_name, model_cls_list +from .base import Serializer, SerializerMeta, DeserializerError + + +class PythonDeserializer: + _mapping = {} + + def deserialize(self, obj): + if isinstance(obj, dict): + typename = obj['__type__'] + deserializer_cls = self.get_deserializer_cls(typename) + return deserializer_cls().deserialize(obj) + elif isinstance(obj, list): + return [self.deserialize(each) for each in obj] + if isinstance(obj, (str, int, float, type(None))): + return obj + raise DeserializerError(f'no deserializer for type:{type(obj)}') + + @classmethod + def get_deserializer_cls(cls, typename): + clz = get_type_by_name(typename) + if clz is None: + raise DeserializerError(f'no deserializer for type:{typename}') + for obj_clz, deserializer_cls in cls._mapping.items(): + if obj_clz == clz: + return deserializer_cls + raise DeserializerError(f'no deserializer for type:{clz}') + + +class ModelDeserializer(PythonDeserializer, metaclass=SerializerMeta): + class Meta: + types = model_cls_list + + def deserialize(self, obj): + model_cls = get_type_by_name(obj['__type__']) + return model_cls.model_validate(obj) class PythonSerializer(Serializer): @@ -11,13 +43,12 @@ class PythonSerializer(Serializer): def __init__(self, **options): super().__init__(**options) - self.opt_level = options.get('level', 0) def serialize_items(self, items): json_ = {} for key, value in items: serializer_cls = PythonSerializer.get_serializer_cls(value) - serializer = serializer_cls(brief=True, level=self.opt_level + 1) + serializer = serializer_cls() json_[key] = serializer.serialize(value) return json_ @@ -26,29 +57,12 @@ def serialize(self, obj): return super().serialize(obj) -class ModelSerializer(PythonSerializer, ModelSerializerMixin): - - def __init__(self, **options): - if options.get('brief') is False and options.get('fetch') is False: - raise SerializerError( - "fetch can't be False when brief is False") - - super().__init__(**options) - self.opt_as_line = options.get('as_line', False) - self.opt_brief = options.get('brief', True) - self.opt_fetch = options.get('fetch', False) - - if self.opt_brief is False: - self.opt_fetch = True +class ModelSerializer(PythonSerializer, metaclass=SerializerMeta): + class Meta: + types = (BaseModel, ) - @attach_typename def serialize(self, model): - dict_ = {} - for field, value in self._get_items(model): - serializer_cls = self.get_serializer_cls(value) - value_dict = serializer_cls(brief=True, fetch=False).serialize(value) - dict_[field] = value_dict - return dict_ + return model.model_dump() ####################### @@ -66,45 +80,16 @@ def serialize(self, list_): result = [] for item in list_: serializer_cls = PythonSerializer.get_serializer_cls(item) - serializer = serializer_cls(brief=True, fetch=False) + serializer = serializer_cls() result.append(serializer.serialize(item)) return result def serialize_search_result_list(self, list_): - serializer = SearchSerializer() + from .objs import SearchPythonSerializer + serializer = SearchPythonSerializer() return [serializer.serialize(model) for model in list_] -################### -# model serializers -################### - - -class SongSerializer(ModelSerializer, SongSerializerMixin, - metaclass=SerializerMeta): - pass - - -class ArtistSerializer(ModelSerializer, ArtistSerializerMixin, - metaclass=SerializerMeta): - pass - - -class AlbumSerializer(ModelSerializer, AlbumSerializerMixin, - metaclass=SerializerMeta): - pass - - -class PlaylistSerializer(ModelSerializer, PlaylistSerializerMixin, - metaclass=SerializerMeta): - pass - - -class UserSerializer(ModelSerializer, UserSerializerMixin, - metaclass=SerializerMeta): - pass - - #################### # object serializers #################### @@ -116,21 +101,3 @@ class Meta: def serialize(self, object): return object - - -class ProviderSerializer(PythonSerializer, - SimpleSerializerMixin, - ProviderSerializerMixin, - metaclass=SerializerMeta): - pass - - -class SearchSerializer(PythonSerializer, SearchSerializerMixin, - metaclass=SerializerMeta): - - def serialize(self, result): - list_serializer = ListSerializer() - json_ = {} - for field, value in self._get_items(result): - json_[field] = list_serializer.serialize(value) - return json_ diff --git a/feeluown/webserver/jsonrpc_.py b/feeluown/webserver/jsonrpc_.py index c3fc3026a1..6de774a06a 100644 --- a/feeluown/webserver/jsonrpc_.py +++ b/feeluown/webserver/jsonrpc_.py @@ -1,7 +1,9 @@ +from functools import wraps + from jsonrpc import JSONRPCResponseManager, Dispatcher from feeluown.fuoexec.fuoexec import fuoexec_get_globals -from feeluown.serializers import serialize +from feeluown.serializers import serialize, deserialize class DynamicDispatcher(Dispatcher): @@ -10,7 +12,22 @@ def __getitem__(self, key): return self.method_map[key] except KeyError: method = eval(key, fuoexec_get_globals()) - return method + return method_wrapper(method) + + +def method_wrapper(func): + @wraps(func) + def wrapper(*args, **kwargs): + new_args = () + if args: + new_args = [deserialize('python', arg) for arg in args] + new_kwargs = {} + if kwargs: + new_kwargs = {} + for k, v in kwargs.items(): + new_kwargs[k] = deserialize('python', v) + return func(*new_args, **new_kwargs) + return wrapper dispatcher = DynamicDispatcher() diff --git a/tests/serializers/test_deserializers.py b/tests/serializers/test_deserializers.py new file mode 100644 index 0000000000..00d04f2233 --- /dev/null +++ b/tests/serializers/test_deserializers.py @@ -0,0 +1,56 @@ +import pytest + +from feeluown.library import SongModel +from feeluown.serializers import serialize, deserialize + + +@pytest.fixture +def asong_data(): + return { + 'album': {'artists_name': '', + 'identifier': '84557', + 'name': '腔·调', + 'source': 'xx'}, + 'album_name': '腔·调', + 'artists': [ + {'identifier': '4445', 'name': '毛阿敏', 'source': 'xx'} + ], + 'artists_name': '毛阿敏', + 'children': [], + 'date': '', + 'disc': '1/1', + 'duration': 181000, + 'duration_ms': '03:01', + 'genre': '', + 'identifier': '106329564', + 'media_flags': 128, + 'pic_url': '', + 'title': '相思', + 'track': '1/1', + 'source': 'xx' + } + + +@pytest.fixture +def asong(asong_data): + return SongModel.model_validate(asong_data) + + +def test_deserializer_basic_types(): + assert deserialize('python', 1) == 1 + assert deserialize('python', 'h') == 'h' + assert deserialize('python', 1.1) == 1.1 + assert deserialize('python', None) is None + + +def test_deserialize_model_from_valid_json(asong): + data = serialize('python', asong) + song2 = deserialize('python', data) + assert song2.artists[0] == asong.artists[0] + + +def test_deserialize_models(asong): + songs = [asong, asong] + data = serialize('python', songs) + songs2 = deserialize('python', data) + assert songs == songs2 diff --git a/tests/serializers/test_serializers.py b/tests/serializers/test_serializers.py index 3f27918c96..b6d7c66ca7 100644 --- a/tests/serializers/test_serializers.py +++ b/tests/serializers/test_serializers.py @@ -1,7 +1,7 @@ from feeluown.app import App from feeluown.player import Player, Playlist from feeluown.serializers import serialize -from feeluown.library import SongModel, SimpleSearchResult +from feeluown.library import SongModel, SimpleSearchResult, AlbumModel from feeluown.player import Metadata @@ -26,15 +26,31 @@ def test_serialize_metadata(): def test_serialize_model(): - song = SongModel(identifier='1', title='', artists=[], duration=0) + song = SongModel( + identifier='1', + title='hello', + album=AlbumModel( + identifier='1', + name='album', + cover='', + artists=[], + songs=[], + description='', + ), + artists=[], + duration=0 + ) song_js = serialize('python', song) assert song_js['identifier'] == '1' + assert song_js['title'] == 'hello' + serialize('plain', song) # should not raise error - song_js = serialize('python', song, brief=True) - assert song_js['identifier'] == '1' - - song_js = serialize('python', song, fetch=True) + song_js = serialize('python', song) assert song_js['identifier'] == '1' + assert song_js['uri'] == 'fuo://dummy/songs/1' + assert song_js['__type__'] == 'feeluown.library.SongModel' + assert song_js['album']['__type__'] == 'feeluown.library.AlbumModel' + assert song_js['album']['uri'] == 'fuo://dummy/albums/1' def test_serialize_search_result(): @@ -42,3 +58,4 @@ def test_serialize_search_result(): result = SimpleSearchResult(q='', songs=[song]) d = serialize('python', result) assert d['songs'][0]['identifier'] == '1' + serialize('plain', result) # should not raise error