From 86f5c8b06bb1b776f4235f36fdd1a707e93aefb5 Mon Sep 17 00:00:00 2001 From: cosven Date: Thu, 22 Aug 2024 01:04:45 +0800 Subject: [PATCH 1/5] serde: remove useless code --- feeluown/serializers/base.py | 2 +- feeluown/serializers/model_helpers.py | 1 + feeluown/serializers/python.py | 41 +++++---------------------- feeluown/webserver/jsonrpc_.py | 21 ++++++++++++++ tests/serializers/test_serializers.py | 4 ++- 5 files changed, 33 insertions(+), 36 deletions(-) diff --git a/feeluown/serializers/base.py b/feeluown/serializers/base.py index 5447fd376c..b96c16c67c 100644 --- a/feeluown/serializers/base.py +++ b/feeluown/serializers/base.py @@ -62,7 +62,7 @@ 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 SerializerMeta(type): diff --git a/feeluown/serializers/model_helpers.py b/feeluown/serializers/model_helpers.py index 96b6fa4a1d..e1c2a40020 100644 --- a/feeluown/serializers/model_helpers.py +++ b/feeluown/serializers/model_helpers.py @@ -33,6 +33,7 @@ def _get_items(self, model): if isinstance(getattr(modelcls, prop), property) and prop not in pydantic_fields] else: + assert False, 'unreachable' fields = self._declared_fields items = [("provider", model.source), ("identifier", str(model.identifier)), diff --git a/feeluown/serializers/python.py b/feeluown/serializers/python.py index 1f7232ae73..06d7e9a840 100644 --- a/feeluown/serializers/python.py +++ b/feeluown/serializers/python.py @@ -1,9 +1,10 @@ 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 .model_helpers import ModelSerializerMixin, ProviderSerializerMixin, \ + SearchSerializerMixin + +from feeluown.library import BaseModel class PythonSerializer(Serializer): @@ -26,7 +27,9 @@ def serialize(self, obj): return super().serialize(obj) -class ModelSerializer(PythonSerializer, ModelSerializerMixin): +class ModelSerializer(PythonSerializer, ModelSerializerMixin, metaclass=SerializerMeta): + class Meta: + types = (BaseModel, ) def __init__(self, **options): if options.get('brief') is False and options.get('fetch') is False: @@ -75,36 +78,6 @@ def serialize_search_result_list(self, list_): 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 #################### diff --git a/feeluown/webserver/jsonrpc_.py b/feeluown/webserver/jsonrpc_.py index c3fc3026a1..61a71daf7c 100644 --- a/feeluown/webserver/jsonrpc_.py +++ b/feeluown/webserver/jsonrpc_.py @@ -1,3 +1,5 @@ +from functools import wraps + from jsonrpc import JSONRPCResponseManager, Dispatcher from feeluown.fuoexec.fuoexec import fuoexec_get_globals @@ -13,6 +15,25 @@ def __getitem__(self, key): return method +def deserialize(obj): + pass + + +def method_wrapper(func): + @wraps(func) + def wrapper(*args, **kwargs): + new_args = () + if args: + new_args = deserialize(args) + new_kwargs = {} + if kwargs: + new_kwargs = {} + for k, v in kwargs.items(): + new_kwargs[k] = deserialize(v) + return func(*new_args, **new_kwargs) + return wrapper + + dispatcher = DynamicDispatcher() diff --git a/tests/serializers/test_serializers.py b/tests/serializers/test_serializers.py index 3f27918c96..fdf26bb045 100644 --- a/tests/serializers/test_serializers.py +++ b/tests/serializers/test_serializers.py @@ -26,9 +26,11 @@ def test_serialize_metadata(): def test_serialize_model(): - song = SongModel(identifier='1', title='', artists=[], duration=0) + song = SongModel(identifier='1', title='hello', 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' From 72d65b9195f25e37f7d3c65df00e03c6c22df853 Mon Sep 17 00:00:00 2001 From: cosven Date: Thu, 22 Aug 2024 01:34:18 +0800 Subject: [PATCH 2/5] clean code: python serializer --- feeluown/serializers/model_helpers.py | 121 -------------------- feeluown/serializers/objs.py | 127 ++++++++++++++++++-- feeluown/serializers/plain.py | 159 ++++++++++++++++---------- feeluown/serializers/python.py | 65 +++++------ tests/serializers/test_serializers.py | 1 + 5 files changed, 242 insertions(+), 231 deletions(-) delete mode 100644 feeluown/serializers/model_helpers.py diff --git a/feeluown/serializers/model_helpers.py b/feeluown/serializers/model_helpers.py deleted file mode 100644 index e1c2a40020..0000000000 --- a/feeluown/serializers/model_helpers.py +++ /dev/null @@ -1,121 +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: - assert False, 'unreachable' - 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..1be015bc0f 100644 --- a/feeluown/serializers/plain.py +++ b/feeluown/serializers/plain.py @@ -1,16 +1,94 @@ 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 + 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: + assert False, 'unreachable' + 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 PlainSerializer(Serializer): """PlainSerializer base class""" _mapping = {} @@ -54,7 +132,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 +163,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 +193,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 +253,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 06d7e9a840..1a1125714e 100644 --- a/feeluown/serializers/python.py +++ b/feeluown/serializers/python.py @@ -1,10 +1,7 @@ -from .typename import attach_typename -from .base import Serializer, SerializerMeta, SerializerError, \ - SimpleSerializerMixin -from .model_helpers import ModelSerializerMixin, ProviderSerializerMixin, \ - SearchSerializerMixin +from feeluown.library import BaseModel, reverse -from feeluown.library import BaseModel +from .typename import attach_typename +from .base import Serializer, SerializerMeta class PythonSerializer(Serializer): @@ -12,13 +9,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_ @@ -27,29 +23,37 @@ def serialize(self, obj): return super().serialize(obj) -class ModelSerializer(PythonSerializer, ModelSerializerMixin, metaclass=SerializerMeta): +class ModelSerializer(PythonSerializer, metaclass=SerializerMeta): class Meta: types = (BaseModel, ) 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 + def _get_items(self, model): + 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 @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) + value_dict = serializer_cls().serialize(value) dict_[field] = value_dict return dict_ @@ -69,12 +73,13 @@ 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_] @@ -89,21 +94,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/tests/serializers/test_serializers.py b/tests/serializers/test_serializers.py index fdf26bb045..6a24b33631 100644 --- a/tests/serializers/test_serializers.py +++ b/tests/serializers/test_serializers.py @@ -44,3 +44,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 From cc3f85694ba670c3ff9860460ff375dbadc05e8d Mon Sep 17 00:00:00 2001 From: cosven Date: Sun, 25 Aug 2024 16:57:50 +0800 Subject: [PATCH 3/5] deserializer works --- feeluown/library/models.py | 29 +++++++++++++- feeluown/serializers/__init__.py | 44 ++++++++++++++------- feeluown/serializers/base.py | 47 ++++++++++++++++++++++ feeluown/serializers/python.py | 52 +++++++++++++++++++++++-- tests/serializers/test_deserializers.py | 39 +++++++++++++++++++ 5 files changed, 193 insertions(+), 18 deletions(-) create mode 100644 tests/serializers/test_deserializers.py diff --git a/feeluown/library/models.py b/feeluown/library/models.py index deb067d209..3838a23821 100644 --- a/feeluown/library/models.py +++ b/feeluown/library/models.py @@ -55,6 +55,7 @@ from typing import List, Optional, Tuple, Any, Union from pydantic import ConfigDict, BaseModel as _BaseModel, PrivateAttr + try: # pydantic>=2.0 from pydantic import field_validator @@ -72,6 +73,21 @@ from .model_state import ModelState +from typing import Annotated +from pydantic import BeforeValidator, model_validator + + +def model_validate(obj: Any): + if isinstance(obj, dict): + js = obj + if 'provider' in js: + js['source'] = js.pop('provider', None) + js.pop('uri', None) + js.pop('__type__', None) + return js + return obj + + TSong = Union['SongModel', 'BriefSongModel'] TAlbum = Union['AlbumModel', 'BriefAlbumModel'] TArtist = Union['ArtistModel', 'BriefArtistModel'] @@ -215,6 +231,17 @@ 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 + 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..814721a1c2 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 SerializerError(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 b96c16c67c..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 @@ -65,6 +76,42 @@ def get_serializer_cls(cls, 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): def __new__(cls, name, bases, attrs): """magic diff --git a/feeluown/serializers/python.py b/feeluown/serializers/python.py index 1a1125714e..a44efde253 100644 --- a/feeluown/serializers/python.py +++ b/feeluown/serializers/python.py @@ -1,7 +1,50 @@ from feeluown.library import BaseModel, reverse -from .typename import attach_typename -from .base import Serializer, SerializerMeta +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): + from typing import Annotated + from pydantic import RootModel + from feeluown.library.models import BeforeValidator, model_validate + + model_cls = get_type_by_name(obj['__type__']) + + class M(RootModel): + root: Annotated[model_cls, BeforeValidator(model_validate)] + + # return M.model_validate(obj).root + return model_cls.model_validate(obj) class PythonSerializer(Serializer): @@ -32,8 +75,9 @@ def __init__(self, **options): def _get_items(self, model): modelcls = type(model) - fields = [field for field in model.__fields__ - if field not in BaseModel.__fields__] + fields = [field for field in model.model_fields + if ((field not in BaseModel.model_fields) + or field in ('identifier', 'source', 'state'))] # Include properties. pydantic_fields = ("__values__", "fields", "__fields_set__", "model_computed_fields", "model_extra", diff --git a/tests/serializers/test_deserializers.py b/tests/serializers/test_deserializers.py new file mode 100644 index 0000000000..3198a3afad --- /dev/null +++ b/tests/serializers/test_deserializers.py @@ -0,0 +1,39 @@ +from feeluown.library import SongModel +from feeluown.serializers import serialize, deserialize + + +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(): + js = { + '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' + } + song = SongModel.model_validate(js) + data = serialize('python', song) + song2 = deserialize('python', data) + assert song2.artists[0] == song.artists[0] From e1e87ecdcd9267bb36596d39c3b6c995ec80ef6b Mon Sep 17 00:00:00 2001 From: cosven Date: Sun, 25 Aug 2024 17:37:08 +0800 Subject: [PATCH 4/5] refactor model serde --- feeluown/library/models.py | 32 +++++++++---------- feeluown/serializers/__init__.py | 2 +- feeluown/serializers/plain.py | 32 ++++++++----------- feeluown/serializers/python.py | 41 ++----------------------- tests/serializers/test_deserializers.py | 41 +++++++++++++++++-------- tests/serializers/test_serializers.py | 26 ++++++++++++---- 6 files changed, 80 insertions(+), 94 deletions(-) diff --git a/feeluown/library/models.py b/feeluown/library/models.py index 3838a23821..a65bf3983b 100644 --- a/feeluown/library/models.py +++ b/feeluown/library/models.py @@ -54,7 +54,10 @@ 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 @@ -73,21 +76,6 @@ from .model_state import ModelState -from typing import Annotated -from pydantic import BeforeValidator, model_validator - - -def model_validate(obj: Any): - if isinstance(obj, dict): - js = obj - if 'provider' in js: - js['source'] = js.pop('provider', None) - js.pop('uri', None) - js.pop('__type__', None) - return js - return obj - - TSong = Union['SongModel', 'BriefSongModel'] TAlbum = Union['AlbumModel', 'BriefAlbumModel'] TArtist = Union['ArtistModel', 'BriefArtistModel'] @@ -242,6 +230,18 @@ def _deserialize(cls, value): 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): """ diff --git a/feeluown/serializers/__init__.py b/feeluown/serializers/__init__.py index 814721a1c2..eafde5a5be 100644 --- a/feeluown/serializers/__init__.py +++ b/feeluown/serializers/__init__.py @@ -36,7 +36,7 @@ def get_deserializer(format_: str): if not _DE_MAPPING: register_deserializer('python', PythonDeserializer) if format_ not in _DE_MAPPING: - raise SerializerError(f"Deserializer for format:{format_} not found") + raise DeserializerError(f"Deserializer for format:{format_} not found") return _DE_MAPPING[format_] diff --git a/feeluown/serializers/plain.py b/feeluown/serializers/plain.py index 1be015bc0f..280be781aa 100644 --- a/feeluown/serializers/plain.py +++ b/feeluown/serializers/plain.py @@ -27,29 +27,21 @@ 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: - assert False, 'unreachable' - fields = self._declared_fields + 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))] - 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'))) + for field in fields: + items.append((field, getattr(model, field))) return items diff --git a/feeluown/serializers/python.py b/feeluown/serializers/python.py index a44efde253..b6bb3b0a91 100644 --- a/feeluown/serializers/python.py +++ b/feeluown/serializers/python.py @@ -1,4 +1,4 @@ -from feeluown.library import BaseModel, reverse +from feeluown.library import BaseModel from .typename import attach_typename, get_type_by_name, model_cls_list from .base import Serializer, SerializerMeta, DeserializerError @@ -34,16 +34,7 @@ class Meta: types = model_cls_list def deserialize(self, obj): - from typing import Annotated - from pydantic import RootModel - from feeluown.library.models import BeforeValidator, model_validate - model_cls = get_type_by_name(obj['__type__']) - - class M(RootModel): - root: Annotated[model_cls, BeforeValidator(model_validate)] - - # return M.model_validate(obj).root return model_cls.model_validate(obj) @@ -70,36 +61,8 @@ class ModelSerializer(PythonSerializer, metaclass=SerializerMeta): class Meta: types = (BaseModel, ) - def __init__(self, **options): - super().__init__(**options) - - def _get_items(self, model): - modelcls = type(model) - fields = [field for field in model.model_fields - if ((field not in BaseModel.model_fields) - or field in ('identifier', 'source', 'state'))] - # 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 - - @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().serialize(value) - dict_[field] = value_dict - return dict_ + return model.model_dump() ####################### diff --git a/tests/serializers/test_deserializers.py b/tests/serializers/test_deserializers.py index 3198a3afad..00d04f2233 100644 --- a/tests/serializers/test_deserializers.py +++ b/tests/serializers/test_deserializers.py @@ -1,16 +1,12 @@ +import pytest + from feeluown.library import SongModel from feeluown.serializers import serialize, deserialize -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(): - js = { +@pytest.fixture +def asong_data(): + return { 'album': {'artists_name': '', 'identifier': '84557', 'name': '腔·调', @@ -33,7 +29,28 @@ def test_deserialize_model_from_valid_json(): 'track': '1/1', 'source': 'xx' } - song = SongModel.model_validate(js) - data = serialize('python', song) + + +@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] == song.artists[0] + 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 6a24b33631..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,17 +26,31 @@ def test_serialize_metadata(): def test_serialize_model(): - song = SongModel(identifier='1', title='hello', 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(): From 2dfe110b4ab6571bd78f74af348420452750bee1 Mon Sep 17 00:00:00 2001 From: cosven Date: Sun, 25 Aug 2024 17:58:02 +0800 Subject: [PATCH 5/5] enable deserialization in jsonrpc --- feeluown/webserver/jsonrpc_.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/feeluown/webserver/jsonrpc_.py b/feeluown/webserver/jsonrpc_.py index 61a71daf7c..6de774a06a 100644 --- a/feeluown/webserver/jsonrpc_.py +++ b/feeluown/webserver/jsonrpc_.py @@ -3,7 +3,7 @@ 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): @@ -12,11 +12,7 @@ def __getitem__(self, key): return self.method_map[key] except KeyError: method = eval(key, fuoexec_get_globals()) - return method - - -def deserialize(obj): - pass + return method_wrapper(method) def method_wrapper(func): @@ -24,12 +20,12 @@ def method_wrapper(func): def wrapper(*args, **kwargs): new_args = () if args: - new_args = deserialize(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(v) + new_kwargs[k] = deserialize('python', v) return func(*new_args, **new_kwargs) return wrapper