From 7edb246b1e527c45436e9f316a64836f27edbbe4 Mon Sep 17 00:00:00 2001 From: spl0k Date: Thu, 11 Jan 2018 23:08:53 +0100 Subject: [PATCH] Py3: str/bytes, iterators, etc. It seems to work on Python 3 now! Ref #75 --- supysonic/api/__init__.py | 11 ++++++-- supysonic/api/annotation.py | 4 +-- supysonic/api/browse.py | 8 +++--- supysonic/api/media.py | 6 ++-- supysonic/api/playlists.py | 44 ++++++++++++++++-------------- supysonic/api/search.py | 20 +++++++------- supysonic/lastfm.py | 16 ++++++----- supysonic/managers/user.py | 2 +- supysonic/scanner.py | 2 +- tests/api/test_api_setup.py | 20 ++++++++------ tests/api/test_playlist.py | 4 +-- tests/api/test_user.py | 9 +++--- tests/base/test_scanner.py | 4 +-- tests/frontend/frontendtestbase.py | 6 +++- tests/frontend/test_user.py | 2 +- tests/testbase.py | 30 ++++++++++++++++++++ tests/utils.py | 14 ++++++++++ 17 files changed, 132 insertions(+), 70 deletions(-) create mode 100644 tests/utils.py diff --git a/supysonic/api/__init__.py b/supysonic/api/__init__.py index 30393e2..245040b 100644 --- a/supysonic/api/__init__.py +++ b/supysonic/api/__init__.py @@ -61,7 +61,7 @@ def decode_password(password): return password try: - return binascii.unhexlify(password[4:]).decode('utf-8') + return binascii.unhexlify(password[4:].encode('utf-8')).decode('utf-8') except: return password @@ -141,14 +141,19 @@ class ResponseHelper: if not isinstance(d, dict): raise TypeError('Expecting a dict') + keys_to_remove = [] for key, value in d.items(): if isinstance(value, dict): d[key] = ResponseHelper.remove_empty_lists(value) elif isinstance(value, list): if len(value) == 0: - del d[key] + keys_to_remove.append(key) else: d[key] = [ ResponseHelper.remove_empty_lists(item) if isinstance(item, dict) else item for item in value ] + + for key in keys_to_remove: + del d[key] + return d @staticmethod @@ -178,7 +183,7 @@ class ResponseHelper: elem = ElementTree.Element('subsonic-response') ResponseHelper.dict2xml(elem, ret) - return minidom.parseString(ElementTree.tostring(elem)).toprettyxml(indent = ' ', encoding = 'UTF-8') + return minidom.parseString(ElementTree.tostring(elem)).toprettyxml(indent = ' ') @staticmethod def dict2xml(elem, dictionary): diff --git a/supysonic/api/annotation.py b/supysonic/api/annotation.py index ac5420b..e7f40d4 100644 --- a/supysonic/api/annotation.py +++ b/supysonic/api/annotation.py @@ -82,7 +82,7 @@ def try_unstar(starred_cls, eid): def merge_errors(errors): error = None - errors = filter(None, errors) + errors = [ e for e in errors if e ] if len(errors) == 1: error = errors[0] elif len(errors) > 1: @@ -149,7 +149,7 @@ def rate(): except: return request.error_formatter(0, 'Invalid parameter') - if not rating in xrange(6): + if not 0 <= rating <= 5: return request.error_formatter(0, 'rating must be between 0 and 5 (inclusive)') with db_session: diff --git a/supysonic/api/browse.py b/supysonic/api/browse.py index 00584b0..8d9f209 100644 --- a/supysonic/api/browse.py +++ b/supysonic/api/browse.py @@ -84,9 +84,9 @@ def list_indexes(): indexes = dict() for artist in artists: index = artist.name[0].upper() - if index in map(str, xrange(10)): + if index in string.digits: index = '#' - elif index not in string.letters: + elif index not in string.ascii_letters: index = '?' if index not in indexes: @@ -132,9 +132,9 @@ def list_artists(): indexes = dict() for artist in Artist.select(): index = artist.name[0].upper() if artist.name else '?' - if index in map(str, xrange(10)): + if index in string.digits: index = '#' - elif index not in string.letters: + elif index not in string.ascii_letters: index = '?' if index not in indexes: diff --git a/supysonic/api/media.py b/supysonic/api/media.py index 3ab8ffa..3d62baf 100644 --- a/supysonic/api/media.py +++ b/supysonic/api/media.py @@ -40,8 +40,10 @@ def prepare_transcoding_cmdline(base_cmdline, input_file, input_format, output_f if not base_cmdline: return None ret = base_cmdline.split() - for i in xrange(len(ret)): - ret[i] = ret[i].replace('%srcpath', input_file).replace('%srcfmt', input_format).replace('%outfmt', output_format).replace('%outrate', str(output_bitrate)) + ret = [ + part.replace('%srcpath', input_file).replace('%srcfmt', input_format).replace('%outfmt', output_format).replace('%outrate', str(output_bitrate)) + for part in ret + ] return ret @app.route('/rest/stream.view', methods = [ 'GET', 'POST' ]) diff --git a/supysonic/api/playlists.py b/supysonic/api/playlists.py index bc7bf90..3cff539 100644 --- a/supysonic/api/playlists.py +++ b/supysonic/api/playlists.py @@ -71,9 +71,8 @@ def create_playlist(): songs = request.values.getlist('songId') try: playlist_id = uuid.UUID(playlist_id) if playlist_id else None - songs = map(uuid.UUID, songs) except: - return request.error_formatter(0, 'Invalid parameter') + return request.error_formatter(0, 'Invalid playlist id') if playlist_id: try: @@ -92,14 +91,17 @@ def create_playlist(): else: return request.error_formatter(10, 'Missing playlist id or name') - for sid in songs: - try: + try: + songs = map(uuid.UUID, songs) + for sid in songs: track = Track[sid] - except ObjectNotFound: - rollback() - return request.error_formatter(70, 'Unknown song') - - playlist.add(track) + playlist.add(track) + except ValueError: + rollback() + return request.error_formatter(0, 'Invalid song id') + except ObjectNotFound: + rollback() + return request.error_formatter(70, 'Unknown song') return request.formatter(dict()) @@ -129,11 +131,6 @@ def update_playlist(): playlist = res name, comment, public = map(request.values.get, [ 'name', 'comment', 'public' ]) to_add, to_remove = map(request.values.getlist, [ 'songIdToAdd', 'songIndexToRemove' ]) - try: - to_add = map(uuid.UUID, to_add) - to_remove = map(int, to_remove) - except: - return request.error_formatter(0, 'Invalid parameter') if name: playlist.name = name @@ -142,14 +139,19 @@ def update_playlist(): if public: playlist.public = public in (True, 'True', 'true', 1, '1') - for sid in to_add: - try: - track = Track[sid] - except ObjectNotFound: - return request.error_formatter(70, 'Unknown song') - playlist.add(track) + try: + to_add = map(uuid.UUID, to_add) + to_remove = map(int, to_remove) - playlist.remove_at_indexes(to_remove) + for sid in to_add: + track = Track[sid] + playlist.add(track) + + playlist.remove_at_indexes(to_remove) + except ValueError: + return request.error_formatter(0, 'Invalid parameter') + except ObjectNotFound: + return request.error_formatter(70, 'Unknown song') return request.formatter(dict()) diff --git a/supysonic/api/search.py b/supysonic/api/search.py index 23b2fdd..20e87bd 100644 --- a/supysonic/api/search.py +++ b/supysonic/api/search.py @@ -94,11 +94,11 @@ def new_search(): albums = select(t.folder for t in Track if query in t.folder.name).limit(album_count, album_offset) songs = Track.select(lambda t: query in t.title).limit(song_count, song_offset) - return request.formatter(dict(searchResult2 = OrderedDict( - artist = [ dict(id = str(a.id), name = a.name) for a in artists ], - album = [ f.as_subsonic_child(request.user) for f in albums ], - song = [ t.as_subsonic_child(request.user, request.client) for t in songs ] - ))) + return request.formatter(dict(searchResult2 = OrderedDict(( + ('artist', [ dict(id = str(a.id), name = a.name) for a in artists ]), + ('album', [ f.as_subsonic_child(request.user) for f in albums ]), + ('song', [ t.as_subsonic_child(request.user, request.client) for t in songs ]) + )))) @app.route('/rest/search3.view', methods = [ 'GET', 'POST' ]) def search_id3(): @@ -123,9 +123,9 @@ def search_id3(): albums = Album.select(lambda a: query in a.name).limit(album_count, album_offset) songs = Track.select(lambda t: query in t.title).limit(song_count, song_offset) - return request.formatter(dict(searchResult3 = OrderedDict( - artist = [ a.as_subsonic_artist(request.user) for a in artists ], - album = [ a.as_subsonic_album(request.user) for a in albums ], - song = [ t.as_subsonic_child(request.user, request.client) for t in songs ] - ))) + return request.formatter(dict(searchResult3 = OrderedDict(( + ('artist', [ a.as_subsonic_artist(request.user) for a in artists ]), + ('album', [ a.as_subsonic_album(request.user) for a in albums ]), + ('song', [ t.as_subsonic_child(request.user, request.client) for t in songs ]) + )))) diff --git a/supysonic/lastfm.py b/supysonic/lastfm.py index e0918c4..3a0359d 100644 --- a/supysonic/lastfm.py +++ b/supysonic/lastfm.py @@ -24,10 +24,13 @@ from .py23 import strtype class LastFm: def __init__(self, config, user, logger): - self.__api_key = config['api_key'] - self.__api_secret = config['secret'] + if config['api_key'] is not None and config['secret'] is not None: + self.__api_key = config['api_key'] + self.__api_secret = config['secret'].encode('utf-8') + self.__enabled = True + else: + self.__enabled = False self.__user = user - self.__enabled = self.__api_key is not None and self.__api_secret is not None self.__logger = logger def link_account(self, token): @@ -75,10 +78,9 @@ class LastFm: sig_str = b'' for k, v in sorted(kwargs.items()): - if isinstance(v, strtype): - sig_str += k + v.encode('utf-8') - else: - sig_str += k + str(v) + k = k.encode('utf-8') + v = v.encode('utf-8') if isinstance(v, strtype) else str(v).encode('utf-8') + sig_str += k + v sig = hashlib.md5(sig_str + self.__api_secret).hexdigest() kwargs['api_sig'] = sig diff --git a/supysonic/managers/user.py b/supysonic/managers/user.py index ebda914..1eac546 100644 --- a/supysonic/managers/user.py +++ b/supysonic/managers/user.py @@ -136,6 +136,6 @@ class UserManager: @staticmethod def __encrypt_password(password, salt = None): if salt is None: - salt = ''.join(random.choice(string.printable.strip()) for i in xrange(6)) + salt = ''.join(random.choice(string.printable.strip()) for _ in range(6)) return hashlib.sha1(salt.encode('utf-8') + password.encode('utf-8')).hexdigest(), salt diff --git a/supysonic/scanner.py b/supysonic/scanner.py index 147b1bf..f423b10 100644 --- a/supysonic/scanner.py +++ b/supysonic/scanner.py @@ -154,7 +154,7 @@ class Scanner: trdict['genre'] = self.__try_read_tag(tag, 'genre') trdict['duration'] = int(tag.info.length) - trdict['bitrate'] = (tag.info.bitrate if hasattr(tag.info, 'bitrate') else int(os.path.getsize(path) * 8 / tag.info.length)) / 1000 + trdict['bitrate'] = (tag.info.bitrate if hasattr(tag.info, 'bitrate') else int(os.path.getsize(path) * 8 / tag.info.length)) // 1000 trdict['content_type'] = mimetypes.guess_type(path, False)[0] or 'application/octet-stream' trdict['last_modification'] = int(os.path.getmtime(path)) diff --git a/tests/api/test_api_setup.py b/tests/api/test_api_setup.py index 04bfcca..c68f342 100644 --- a/tests/api/test_api_setup.py +++ b/tests/api/test_api_setup.py @@ -5,38 +5,42 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2017 Alban 'spl0k' Féron -# 2017 Óscar García Amor +# Copyright (C) 2017-2018 Alban 'spl0k' Féron +# 2017 Óscar García Amor # # Distributed under terms of the GNU AGPLv3 license. import base64 -import binascii import simplejson from xml.etree import ElementTree from ..testbase import TestBase +from ..utils import hexlify class ApiSetupTestCase(TestBase): __with_api__ = True + def setUp(self): + super(ApiSetupTestCase, self).setUp() + self._patch_client() + def __basic_auth_get(self, username, password): - hashed = base64.b64encode('{}:{}'.format(username, password)) - headers = { 'Authorization': 'Basic ' + hashed } + hashed = base64.b64encode('{}:{}'.format(username, password).encode('utf-8')) + headers = { 'Authorization': 'Basic ' + hashed.decode('utf-8') } return self.client.get('/rest/ping.view', headers = headers, query_string = { 'c': 'tests' }) def __query_params_auth_get(self, username, password): return self.client.get('/rest/ping.view', query_string = { 'c': 'tests', 'u': username, 'p': password }) def __query_params_auth_enc_get(self, username, password): - return self.__query_params_auth_get(username, 'enc:' + binascii.hexlify(password)) + return self.__query_params_auth_get(username, 'enc:' + hexlify(password)) def __form_auth_post(self, username, password): return self.client.post('/rest/ping.view', data = { 'c': 'tests', 'u': username, 'p': password }) def __form_auth_enc_post(self, username, password): - return self.__form_auth_post(username, 'enc:' + binascii.hexlify(password)) + return self.__form_auth_post(username, 'enc:' + hexlify(password)) def __test_auth(self, method): # non-existent user @@ -66,7 +70,7 @@ class ApiSetupTestCase(TestBase): self.__test_auth(self.__basic_auth_get) # Shouldn't accept 'enc:' passwords - rv = self.__basic_auth_get('alice', 'enc:' + binascii.hexlify('Alic3')) + rv = self.__basic_auth_get('alice', 'enc:' + hexlify('Alic3')) self.assertEqual(rv.status_code, 401) self.assertIn('status="failed"', rv.data) self.assertIn('code="40"', rv.data) diff --git a/tests/api/test_playlist.py b/tests/api/test_playlist.py index 0050696..eeb8b16 100644 --- a/tests/api/test_playlist.py +++ b/tests/api/test_playlist.py @@ -5,7 +5,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2017 Alban 'spl0k' Féron +# Copyright (C) 2017-2018 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -140,7 +140,7 @@ class PlaylistTestCase(ApiTestBase): # create more useful playlist with db_session: songs = { s.title: str(s.id) for s in Track.select() } - self._make_request('createPlaylist', { 'name': 'songs', 'songId': map(lambda s: songs[s], [ 'Three', 'One', 'Two' ]) }, skip_post = True) + self._make_request('createPlaylist', { 'name': 'songs', 'songId': list(map(lambda s: songs[s], [ 'Three', 'One', 'Two' ])) }, skip_post = True) with db_session: playlist = Playlist.get(name = 'songs') self.assertIsNotNone(playlist) diff --git a/tests/api/test_user.py b/tests/api/test_user.py index e97a617..ec00a5b 100644 --- a/tests/api/test_user.py +++ b/tests/api/test_user.py @@ -5,13 +5,12 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2017 Alban 'spl0k' Féron -# 2017 Óscar García Amor +# Copyright (C) 2017-2018 Alban 'spl0k' Féron +# 2017 Óscar García Amor # # Distributed under terms of the GNU AGPLv3 license. -import binascii - +from ..utils import hexlify from .apitestbase import ApiTestBase class UserTestCase(ApiTestBase): @@ -141,7 +140,7 @@ class UserTestCase(ApiTestBase): self._make_request('changePassword', { 'username': 'alice', 'password': 'Alic3', 'u': 'alice', 'p': 'новыйпароль' }, skip_post = True) # non ASCII in hex encoded password - self._make_request('changePassword', { 'username': 'alice', 'password': 'enc:' + binascii.hexlify('новыйпароль') }, skip_post = True) + self._make_request('changePassword', { 'username': 'alice', 'password': 'enc:' + hexlify(u'новыйпароль') }, skip_post = True) self._make_request('ping', { 'u': 'alice', 'p': 'новыйпароль' }) # new password starting with 'enc:' followed by non hex chars diff --git a/tests/base/test_scanner.py b/tests/base/test_scanner.py index 63d68e5..1ce7683 100644 --- a/tests/base/test_scanner.py +++ b/tests/base/test_scanner.py @@ -5,7 +5,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2017 Alban 'spl0k' Féron +# Copyright (C) 2017-2018 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -147,7 +147,7 @@ class ScannerTestCase(unittest.TestCase): self.assertEqual(db.Track.select().count(), 2) tf.seek(0, 0) - tf.write('\x00' * 4096) + tf.write(b'\x00' * 4096) tf.truncate() self.scanner.scan(db.Folder[self.folderid]) diff --git a/tests/frontend/frontendtestbase.py b/tests/frontend/frontendtestbase.py index e290836..b4e1443 100644 --- a/tests/frontend/frontendtestbase.py +++ b/tests/frontend/frontendtestbase.py @@ -4,7 +4,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2017 Alban 'spl0k' Féron +# Copyright (C) 2017-2018 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. @@ -13,6 +13,10 @@ from ..testbase import TestBase class FrontendTestBase(TestBase): __with_webui__ = True + def setUp(self): + super(FrontendTestBase, self).setUp() + self._patch_client() + def _login(self, username, password): return self.client.post('/user/login', data = { 'user': username, 'password': password }, follow_redirects = True) diff --git a/tests/frontend/test_user.py b/tests/frontend/test_user.py index 26bd89d..fcdd0b3 100644 --- a/tests/frontend/test_user.py +++ b/tests/frontend/test_user.py @@ -5,7 +5,7 @@ # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright (C) 2013-2017 Alban 'spl0k' Féron +# Copyright (C) 2017 Alban 'spl0k' Féron # # Distributed under terms of the GNU AGPLv3 license. diff --git a/tests/testbase.py b/tests/testbase.py index cfa2803..9dd8815 100644 --- a/tests/testbase.py +++ b/tests/testbase.py @@ -53,6 +53,32 @@ class TestConfig(DefaultConfig): 'mount_api': with_api }) +class MockResponse(object): + def __init__(self, response): + self.__status_code = response.status_code + self.__data = response.get_data(as_text = True) + self.__mimetype = response.mimetype + + @property + def status_code(self): + return self.__status_code + + @property + def data(self): + return self.__data + + @property + def mimetype(self): + return self.__mimetype + +def patch_method(f): + original = f + def patched(*args, **kwargs): + rv = original(*args, **kwargs) + return MockResponse(rv) + + return patched + class TestBase(unittest.TestCase): __with_webui__ = False __with_api__ = False @@ -76,6 +102,10 @@ class TestBase(unittest.TestCase): UserManager.add('alice', 'Alic3', 'test@example.com', True) UserManager.add('bob', 'B0b', 'bob@example.com', False) + def _patch_client(self): + self.client.get = patch_method(self.client.get) + self.client.post = patch_method(self.client.post) + @staticmethod def __should_unload_module(module): if module.startswith('supysonic'): diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..b1df541 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,14 @@ +# coding: utf-8 +# +# This file is part of Supysonic. +# Supysonic is a Python implementation of the Subsonic server API. +# +# Copyright (C) 2018 Alban 'spl0k' Féron +# +# Distributed under terms of the GNU AGPLv3 license. + +import binascii + +def hexlify(s): + return binascii.hexlify(s.encode('utf-8')).decode('utf-8') +