diff --git a/.travis.yml b/.travis.yml index 419756d..35ab9c8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,6 @@ dist: xenial language: python python: - - 2.7 - 3.5 - 3.6 - 3.7 diff --git a/README.md b/README.md index b50c784..ffcbfe1 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ _Supysonic_ is a Python implementation of the [Subsonic][] server API. [![Build Status](https://travis-ci.org/spl0k/supysonic.svg?branch=master)](https://travis-ci.org/spl0k/supysonic) [![codecov](https://codecov.io/gh/spl0k/supysonic/branch/master/graph/badge.svg)](https://codecov.io/gh/spl0k/supysonic) -![Python](https://img.shields.io/badge/python-2.7%2C%203.5%2C%203.6%2C%203.7-blue.svg) +![Python](https://img.shields.io/badge/python-3.5%2C%203.6%2C%203.7-blue.svg) Current supported features are: * browsing (by folders or tags) @@ -51,20 +51,20 @@ or $ pip install . -but not both. Please note that the `pip` method doesn't seem to work with -Python 2.7. +but not both. ### Prerequisites You'll need these to run _Supysonic_: -* Python 2.7 or >= 3.5 +* Python >= 3.5 * [Flask](http://flask.pocoo.org/) * [PonyORM](https://ponyorm.com/) * [Python Imaging Library](https://github.com/python-pillow/Pillow) * [requests](http://docs.python-requests.org/) * [mutagen](https://mutagen.readthedocs.io/en/latest/) * [watchdog](https://github.com/gorakhargosh/watchdog) +* [zipstream](https://github.com/allanlei/python-zipstream) All the dependencies will automatically be installed by the installation command above. @@ -142,7 +142,7 @@ _Supysonic_ can run as a WSGI application with the `cgi-bin/supysonic.wsgi` file. To run it within an _Apache2_ server, first you need to install the WSGI module and enable it. - $ apt install libapache2-mod-wsgi + $ apt install libapache2-mod-wsgi-py3 $ a2enmod wsgi Next, edit the _Apache_ configuration to load the application. Here's a basic diff --git a/setup.py b/setup.py index d405399..cd32d36 100755 --- a/setup.py +++ b/setup.py @@ -20,7 +20,6 @@ reqs = [ "Pillow", "requests>=1.0.0", "mutagen>=1.33", - "scandir<2.0.0; python_version <= '2.7'", "watchdog>=0.8.0", "zipstream", ] @@ -55,8 +54,6 @@ setup( "Intended Audience :: End Users/Desktop", "Intended Audience :: System Administrators", "License :: OSI Approved :: GNU Affero General Public License v3", - "Programming Language :: Python :: 2", - "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", diff --git a/supysonic/api/__init__.py b/supysonic/api/__init__.py index e14d7b7..0e7b1ba 100644 --- a/supysonic/api/__init__.py +++ b/supysonic/api/__init__.py @@ -18,7 +18,6 @@ from pony.orm import ObjectNotFound from pony.orm import commit from ..managers.user import UserManager -from ..py23 import dict from .exceptions import Unauthorized from .formatters import JSONFormatter, JSONPFormatter, XMLFormatter diff --git a/supysonic/api/albums_songs.py b/supysonic/api/albums_songs.py index 0e84a5d..1246b7b 100644 --- a/supysonic/api/albums_songs.py +++ b/supysonic/api/albums_songs.py @@ -24,7 +24,6 @@ from ..db import ( User, ) from ..db import now -from ..py23 import dict from . import api from .exceptions import GenericError, NotFound diff --git a/supysonic/api/annotation.py b/supysonic/api/annotation.py index 2b8b64c..3a8e736 100644 --- a/supysonic/api/annotation.py +++ b/supysonic/api/annotation.py @@ -19,7 +19,6 @@ from ..db import Track, Album, Artist, Folder, User from ..db import StarredTrack, StarredAlbum, StarredArtist, StarredFolder from ..db import RatingTrack, RatingFolder from ..lastfm import LastFm -from ..py23 import dict from . import api, get_entity, get_entity_id from .exceptions import AggregateException, GenericError, MissingParameter, NotFound @@ -150,7 +149,9 @@ def rate(): if rating == 0: if tid is not None: delete( - r for r in RatingTrack if r.user.id == request.user.id and r.rated.id == tid + r + for r in RatingTrack + if r.user.id == request.user.id and r.rated.id == tid ) else: delete( diff --git a/supysonic/api/browse.py b/supysonic/api/browse.py index 884b066..3797884 100644 --- a/supysonic/api/browse.py +++ b/supysonic/api/browse.py @@ -14,7 +14,6 @@ from flask import request from pony.orm import ObjectNotFound, select from ..db import Folder, Artist, Album, Track -from ..py23 import dict from . import api, get_entity, get_entity_id diff --git a/supysonic/api/chat.py b/supysonic/api/chat.py index f7535d0..b40f0e8 100644 --- a/supysonic/api/chat.py +++ b/supysonic/api/chat.py @@ -10,7 +10,6 @@ from flask import request from ..db import ChatMessage, User -from ..py23 import dict from . import api diff --git a/supysonic/api/formatters.py b/supysonic/api/formatters.py index 4448afb..9755f74 100644 --- a/supysonic/api/formatters.py +++ b/supysonic/api/formatters.py @@ -10,7 +10,6 @@ from flask import json, jsonify, make_response from xml.etree import ElementTree -from ..py23 import dict, strtype from . import API_VERSION @@ -103,7 +102,7 @@ class XMLFormatter(BaseFormatter): """ if not isinstance(dictionary, dict): raise TypeError("Expecting a dict") - if not all(map(lambda x: isinstance(x, strtype), dictionary)): + if not all(map(lambda x: isinstance(x, str), dictionary)): raise TypeError("Dictionary keys must be strings") for name, value in dictionary.items(): @@ -125,7 +124,7 @@ class XMLFormatter(BaseFormatter): def __value_tostring(self, value): if value is None: return None - if isinstance(value, strtype): + if isinstance(value, str): return value if isinstance(value, bool): return str(value).lower() diff --git a/supysonic/api/media.py b/supysonic/api/media.py index c2e9370..c76e2f8 100644 --- a/supysonic/api/media.py +++ b/supysonic/api/media.py @@ -8,7 +8,6 @@ # # Distributed under terms of the GNU AGPLv3 license. -import codecs import logging import mimetypes import os.path @@ -33,7 +32,6 @@ from .. import scanner from ..cache import CacheMiss from ..covers import get_embedded_cover from ..db import Track, Album, Artist, Folder, User, ClientPrefs, now -from ..py23 import dict from . import api, get_entity, get_entity_id from .exceptions import ( @@ -137,9 +135,7 @@ def stream_media(): raise GenericError(message) transcoder, decoder, encoder = [ - prepare_transcoding_cmdline( - x, res, src_suffix, dst_suffix, dst_bitrate - ) + prepare_transcoding_cmdline(x, res, src_suffix, dst_suffix, dst_bitrate) for x in (transcoder, decoder, encoder) ] try: @@ -304,7 +300,8 @@ def lyrics(): logger.debug("Found lyrics file: " + lyrics_path) try: - lyrics = read_file_as_unicode(lyrics_path) + with open(lyrics_path, "rt") as f: + lyrics = f.read() except UnicodeError: # Lyrics file couldn't be decoded. Rather than displaying an error, try with the potential next files or # return no lyrics. Log it anyway. @@ -350,22 +347,3 @@ def lyrics(): logger.warning("Error while requesting the ChartLyrics API: " + str(e)) return request.formatter("lyrics", lyrics) - - -def read_file_as_unicode(path): - """ Opens a file trying with different encodings and returns the contents as a unicode string """ - - encodings = ["utf-8", "latin1"] # Should be extended to support more encodings - - for enc in encodings: - try: - contents = codecs.open(path, "r", encoding=enc).read() - logger.debug("Read file {} with {} encoding".format(path, enc)) - # Maybe save the encoding somewhere to prevent going through this loop each time for the same file - return contents - except UnicodeError: - pass - - # Fallback to ASCII - logger.debug("Reading file {} with ascii encoding".format(path)) - return unicode(open(path, "r").read()) diff --git a/supysonic/api/playlists.py b/supysonic/api/playlists.py index f7315bc..056a2de 100644 --- a/supysonic/api/playlists.py +++ b/supysonic/api/playlists.py @@ -12,7 +12,6 @@ import uuid from flask import request from ..db import Playlist, User, Track -from ..py23 import dict from . import api, get_entity from .exceptions import Forbidden, MissingParameter, NotFound diff --git a/supysonic/api/search.py b/supysonic/api/search.py index 32b14bc..a1d7524 100644 --- a/supysonic/api/search.py +++ b/supysonic/api/search.py @@ -13,7 +13,6 @@ from flask import request from pony.orm import select from ..db import Folder, Track, Artist, Album -from ..py23 import dict from . import api from .exceptions import MissingParameter diff --git a/supysonic/api/system.py b/supysonic/api/system.py index d0c8587..95a9027 100644 --- a/supysonic/api/system.py +++ b/supysonic/api/system.py @@ -9,7 +9,6 @@ from flask import request -from ..py23 import dict from . import api diff --git a/supysonic/api/user.py b/supysonic/api/user.py index b906572..498348a 100644 --- a/supysonic/api/user.py +++ b/supysonic/api/user.py @@ -12,7 +12,6 @@ from functools import wraps from ..db import User from ..managers.user import UserManager -from ..py23 import dict from . import api, decode_password from .exceptions import Forbidden, GenericError, NotFound diff --git a/supysonic/cache.py b/supysonic/cache.py index 0b4bd1c..3b18727 100644 --- a/supysonic/cache.py +++ b/supysonic/cache.py @@ -18,8 +18,6 @@ import tempfile import threading from time import time -from .py23 import scandir, osreplace - logger = logging.getLogger(__name__) @@ -80,7 +78,7 @@ class Cache(object): for mtime, size, key in sorted( [ (f.stat().st_mtime, f.stat().st_size, f.name) - for f in scandir(self._cache_dir) + for f in os.scandir(self._cache_dir) if f.is_file() ] ): @@ -158,7 +156,7 @@ class Cache(object): with self._lock: if self._auto_prune: self._make_space(size, key=key) - osreplace(f.name, self._filepath(key)) + os.replace(f.name, self._filepath(key)) self._record_file(key, size) except OSError as e: # Ignore error from trying to delete the renamed temp file diff --git a/supysonic/config.py b/supysonic/config.py index de9d5ea..7a45c4c 100644 --- a/supysonic/config.py +++ b/supysonic/config.py @@ -8,14 +8,11 @@ # # Distributed under terms of the GNU AGPLv3 license. -try: - from configparser import RawConfigParser -except ImportError: - from ConfigParser import RawConfigParser - import os import tempfile +from configparser import RawConfigParser + current_config = None @@ -92,11 +89,6 @@ class IniConfig(DefaultConfig): return True if lv in ("no", "false", "off"): return False - try: - if isinstance(value, unicode): - return str(value) - except NameError: - pass return value @classmethod diff --git a/supysonic/covers.py b/supysonic/covers.py index afd79ce..720fe6a 100644 --- a/supysonic/covers.py +++ b/supysonic/covers.py @@ -16,8 +16,8 @@ from mutagen.easyid3 import EasyID3 from mutagen.flac import FLAC, Picture from mutagen._vorbis import VCommentDict from PIL import Image +from os import scandir -from .py23 import scandir, strtype EXTENSIONS = (".jpg", ".jpeg", ".png", ".bmp") NAMING_SCORE_RULES = ( @@ -90,7 +90,7 @@ def find_cover_in_folder(path, album_name=None): def get_embedded_cover(path): - if not isinstance(path, strtype): # pragma: nocover + if not isinstance(path, str): # pragma: nocover raise TypeError("Expecting string, got " + str(type(path))) if not os.path.exists(path): diff --git a/supysonic/daemon/client.py b/supysonic/daemon/client.py index 9ebfebc..981c1b9 100644 --- a/supysonic/daemon/client.py +++ b/supysonic/daemon/client.py @@ -11,7 +11,6 @@ from multiprocessing.connection import Client from .exceptions import DaemonUnavailableError from ..config import get_current_config -from ..py23 import strtype from ..utils import get_secret_key __all__ = ["DaemonClient"] @@ -86,13 +85,13 @@ class DaemonClient(object): ) def add_watched_folder(self, folder): - if not isinstance(folder, strtype): + if not isinstance(folder, str): raise TypeError("Expecting string, got " + str(type(folder))) with self.__get_connection() as c: c.send(AddWatchedFolderCommand(folder)) def remove_watched_folder(self, folder): - if not isinstance(folder, strtype): + if not isinstance(folder, str): raise TypeError("Expecting string, got " + str(type(folder))) with self.__get_connection() as c: c.send(RemoveWatchedFolder(folder)) diff --git a/supysonic/db.py b/supysonic/db.py index 2da1152..f619063 100644 --- a/supysonic/db.py +++ b/supysonic/db.py @@ -20,15 +20,9 @@ from pony.orm import ObjectNotFound, DatabaseError from pony.orm import buffer from pony.orm import min, max, avg, sum, exists from pony.orm import db_session +from urllib.parse import urlparse, parse_qsl from uuid import UUID, uuid4 -from .py23 import dict, strtype - -try: - from urllib.parse import urlparse, parse_qsl -except ImportError: - from urlparse import urlparse, parse_qsl - SCHEMA_VERSION = "20190915" @@ -538,7 +532,7 @@ class Playlist(db.Entity): tid = track elif isinstance(track, Track): tid = track.id - elif isinstance(track, strtype): + elif isinstance(track, str): tid = UUID(track) if self.tracks and len(self.tracks) > 0: @@ -557,7 +551,7 @@ class Playlist(db.Entity): def parse_uri(database_uri): - if not isinstance(database_uri, strtype): + if not isinstance(database_uri, str): raise TypeError("Expecting a string") uri = urlparse(database_uri) diff --git a/supysonic/frontend/user.py b/supysonic/frontend/user.py index 8cac6de..46d2582 100644 --- a/supysonic/frontend/user.py +++ b/supysonic/frontend/user.py @@ -17,7 +17,6 @@ from pony.orm import ObjectNotFound from ..db import User, ClientPrefs from ..lastfm import LastFm from ..managers.user import UserManager -from ..py23 import dict from . import admin_only, frontend diff --git a/supysonic/lastfm.py b/supysonic/lastfm.py index b7424b6..8f260b5 100644 --- a/supysonic/lastfm.py +++ b/supysonic/lastfm.py @@ -11,8 +11,6 @@ import hashlib import logging import requests -from .py23 import strtype - logger = logging.getLogger(__name__) @@ -87,7 +85,7 @@ class LastFm: sig_str = b"" for k, v in sorted(kwargs.items()): k = k.encode("utf-8") - v = v.encode("utf-8") if isinstance(v, strtype) else str(v).encode("utf-8") + v = v.encode("utf-8") if isinstance(v, str) else str(v).encode("utf-8") sig_str += k + v sig = hashlib.md5(sig_str + self.__api_secret).hexdigest() diff --git a/supysonic/managers/folder.py b/supysonic/managers/folder.py index eede1da..36862e9 100644 --- a/supysonic/managers/folder.py +++ b/supysonic/managers/folder.py @@ -16,7 +16,6 @@ from pony.orm import ObjectNotFound from ..daemon.client import DaemonClient from ..daemon.exceptions import DaemonUnavailableError from ..db import Folder, Track, Artist, Album, User, RatingTrack, StarredTrack -from ..py23 import strtype class FolderManager: diff --git a/supysonic/managers/user.py b/supysonic/managers/user.py index 225ebb9..be7fda3 100644 --- a/supysonic/managers/user.py +++ b/supysonic/managers/user.py @@ -16,7 +16,6 @@ import uuid from pony.orm import ObjectNotFound from ..db import User -from ..py23 import strtype class UserManager: @@ -24,7 +23,7 @@ class UserManager: def get(uid): if isinstance(uid, uuid.UUID): pass - elif isinstance(uid, strtype): + elif isinstance(uid, str): uid = uuid.UUID(uid) else: raise ValueError("Invalid user id") diff --git a/supysonic/py23.py b/supysonic/py23.py deleted file mode 100644 index 653f7aa..0000000 --- a/supysonic/py23.py +++ /dev/null @@ -1,70 +0,0 @@ -# coding: utf-8 -# -# This file is part of Supysonic. -# Supysonic is a Python implementation of the Subsonic server API. -# -# Copyright (C) 2018-2019 Alban 'spl0k' FĂ©ron -# 2018-2019 Carey 'pR0Ps' Metcalfe -# -# Distributed under terms of the GNU AGPLv3 license. - -# Try built-in scandir, fall back to the package for Python 2.7 -try: - from os import scandir -except ImportError: - from scandir import scandir - -# os.replace was added in Python 3.3, provide a fallback for Python 2.7 -try: - from os import replace as osreplace -except ImportError: - # os.rename is equivalent to os.replace except on Windows - # On Windows an existing file will not be overwritten - # This fallback just attempts to delete the dst file before using rename - import sys - - if sys.platform != "win32": - from os import rename as osreplace - else: - import os - - def osreplace(src, dst): - try: - os.remove(dst) - except OSError: - pass - os.rename(src, dst) - - -try: - from queue import Queue, Empty as QueueEmpty -except ImportError: - from Queue import Queue, Empty as QueueEmpty - -try: - # Python 2 - strtype = basestring - - _builtin_dict = dict - - class DictMeta(type): - def __instancecheck__(cls, instance): - return isinstance(instance, _builtin_dict) - - class dict(dict): - __metaclass__ = DictMeta - - def keys(self): - return self.viewkeys() - - def values(self): - return self.viewvalues() - - def items(self): - return self.viewitems() - - -except NameError: - # Python 3 - strtype = str - dict = dict diff --git a/supysonic/scanner.py b/supysonic/scanner.py index d9f441a..e4ed60b 100644 --- a/supysonic/scanner.py +++ b/supysonic/scanner.py @@ -14,13 +14,13 @@ import time from datetime import datetime from pony.orm import db_session +from queue import Queue, Empty as QueueEmpty from threading import Thread, Event from .covers import find_cover_in_folder, has_embedded_cover, CoverFile from .db import Folder, Artist, Album, Track, User from .db import StarredFolder, StarredArtist, StarredAlbum, StarredTrack from .db import RatingFolder, RatingTrack -from .py23 import scandir, strtype, Queue, QueueEmpty logger = logging.getLogger(__name__) @@ -93,7 +93,7 @@ class Scanner(Thread): self.__progress(folder_name, scanned) def queue_folder(self, folder_name): - if not isinstance(folder_name, strtype): + if not isinstance(folder_name, str): raise TypeError("Expecting string, got " + str(type(folder_name))) self.__queue.put(folder_name) @@ -131,7 +131,7 @@ class Scanner(Thread): scanned = 0 while not self.__stopped.is_set() and to_scan: path = to_scan.pop() - for entry in scandir(path): + for entry in os.scandir(path): if entry.name.startswith("."): continue if entry.is_symlink() and not self.__follow_symlinks: @@ -194,7 +194,7 @@ class Scanner(Thread): @db_session def scan_file(self, path_or_direntry): - if isinstance(path_or_direntry, strtype): + if isinstance(path_or_direntry, str): path = path_or_direntry if not os.path.exists(path): @@ -286,7 +286,7 @@ class Scanner(Thread): @db_session def remove_file(self, path): - if not isinstance(path, strtype): + if not isinstance(path, str): raise TypeError("Expecting string, got " + str(type(path))) tr = Track.get(path=path) @@ -298,9 +298,9 @@ class Scanner(Thread): @db_session def move_file(self, src_path, dst_path): - if not isinstance(src_path, strtype): + if not isinstance(src_path, str): raise TypeError("Expecting string, got " + str(type(src_path))) - if not isinstance(dst_path, strtype): + if not isinstance(dst_path, str): raise TypeError("Expecting string, got " + str(type(dst_path))) if src_path == dst_path: @@ -326,7 +326,7 @@ class Scanner(Thread): @db_session def find_cover(self, dirpath): - if not isinstance(dirpath, strtype): # pragma: nocover + if not isinstance(dirpath, str): # pragma: nocover raise TypeError("Expecting string, got " + str(type(dirpath))) if not os.path.exists(dirpath): @@ -346,7 +346,7 @@ class Scanner(Thread): @db_session def add_cover(self, path): - if not isinstance(path, strtype): # pragma: nocover + if not isinstance(path, str): # pragma: nocover raise TypeError("Expecting string, got " + str(type(path))) folder = Folder.get(path=os.path.dirname(path)) diff --git a/supysonic/watcher.py b/supysonic/watcher.py index f72b588..9b8c194 100644 --- a/supysonic/watcher.py +++ b/supysonic/watcher.py @@ -18,7 +18,6 @@ from watchdog.events import PatternMatchingEventHandler from . import covers from .db import Folder -from .py23 import dict, strtype from .scanner import Scanner OP_SCAN = 1 @@ -267,7 +266,7 @@ class SupysonicWatcher(object): def add_folder(self, folder): if isinstance(folder, Folder): path = folder.path - elif isinstance(folder, strtype): + elif isinstance(folder, str): path = folder else: raise TypeError("Expecting string or Folder, got " + str(type(folder))) @@ -279,7 +278,7 @@ class SupysonicWatcher(object): def remove_folder(self, folder): if isinstance(folder, Folder): path = folder.path - elif isinstance(folder, strtype): + elif isinstance(folder, str): path = folder else: raise TypeError("Expecting string or Folder, got " + str(type(folder))) diff --git a/tests/api/apitestbase.py b/tests/api/apitestbase.py index 24f3f20..2269f18 100644 --- a/tests/api/apitestbase.py +++ b/tests/api/apitestbase.py @@ -11,7 +11,6 @@ import re from lxml import etree from supysonic.managers.user import UserManager -from supysonic.py23 import strtype from ..testbase import TestBase @@ -68,7 +67,7 @@ class ApiTestBase(TestBase): if not isinstance(args, dict): raise TypeError("'args', expecting a dict, got " + type(args).__name__) - if tag and not isinstance(tag, strtype): + if tag and not isinstance(tag, str): raise TypeError("'tag', expecting a str, got " + type(tag).__name__) args.update({"c": "tests", "v": "1.9.0"}) diff --git a/tests/api/test_response_helper.py b/tests/api/test_response_helper.py index 1200ead..4ea9806 100644 --- a/tests/api/test_response_helper.py +++ b/tests/api/test_response_helper.py @@ -14,7 +14,6 @@ import flask.json from xml.etree import ElementTree from supysonic.api.formatters import JSONFormatter, JSONPFormatter, XMLFormatter -from supysonic.py23 import strtype from ..testbase import TestBase @@ -98,7 +97,7 @@ class ResponseHelperJsonTestCase(TestBase, UnwrapperMixin.create_from(JSONFormat self.assertIn("list", d) self.assertNotIn("emptyList", d) self.assertIn("subdict", d) - self.assertIsInstance(d["value"], strtype) + self.assertIsInstance(d["value"], str) self.assertIsInstance(d["list"], list) self.assertIsInstance(d["subdict"], dict) diff --git a/tests/base/test_cli.py b/tests/base/test_cli.py index f0174d4..c8b8218 100644 --- a/tests/base/test_cli.py +++ b/tests/base/test_cli.py @@ -15,13 +15,9 @@ import tempfile import unittest from contextlib import contextmanager +from io import StringIO from pony.orm import db_session -try: # Don't use io.StringIO on py2, it only accepts unicode and the CLI spits strs - from StringIO import StringIO -except ImportError: - from io import StringIO - from supysonic.db import Folder, User, init_database, release_database from supysonic.cli import SupysonicCLI diff --git a/tests/base/test_config.py b/tests/base/test_config.py index 5d2a060..4340401 100644 --- a/tests/base/test_config.py +++ b/tests/base/test_config.py @@ -11,7 +11,6 @@ import unittest from supysonic.config import IniConfig -from supysonic.py23 import strtype class ConfigTestCase(unittest.TestCase): @@ -26,7 +25,7 @@ class ConfigTestCase(unittest.TestCase): self.assertIsInstance(conf.TYPES["float"], float) self.assertIsInstance(conf.TYPES["int"], int) - self.assertIsInstance(conf.TYPES["string"], strtype) + self.assertIsInstance(conf.TYPES["string"], str) for t in ("bool", "switch", "yn"): self.assertIsInstance(conf.BOOLEANS[t + "_false"], bool) diff --git a/tests/managers/test_manager_user.py b/tests/managers/test_manager_user.py index a8a6946..7b64213 100644 --- a/tests/managers/test_manager_user.py +++ b/tests/managers/test_manager_user.py @@ -11,7 +11,6 @@ from supysonic import db from supysonic.managers.user import UserManager -from supysonic.py23 import strtype import io import unittest