Repository: mopidy/mopidy-soundcloud

Issue:
Port mopidy-soundcloud to Python 3
Tracking issue for the porting of this extension to Python 3.

Discussion:

Patch:
diff --git a/.circleci/config.yml b/.circleci/config.yml
new file mode 100644
index 0000000..bfd4689
--- /dev/null
+++ b/.circleci/config.yml
@@ -0,0 +1,51 @@
+version: 2.1
+
+orbs:
+  codecov: codecov/codecov@1.0.5
+
+workflows:
+  version: 2
+  test:
+    jobs:
+      - py38
+      - py37
+      - black
+      - check-manifest
+      - flake8
+
+jobs:
+  py38: &test-template
+    docker:
+      - image: mopidy/ci-python:3.8
+    steps:
+      - checkout
+      - restore_cache:
+          name: Restoring tox cache
+          key: tox-v1-{{ .Environment.CIRCLE_JOB }}-{{ checksum "setup.cfg" }}
+      - run:
+          name: Run tests
+          command: |
+            tox -e $CIRCLE_JOB -- \
+              --junit-xml=test-results/pytest/results.xml \
+              --cov-report=xml
+      - save_cache:
+          name: Saving tox cache
+          key: tox-v1-{{ .Environment.CIRCLE_JOB }}-{{ checksum "setup.cfg" }}
+          paths:
+            - ./.tox
+            - ~/.cache/pip
+      - codecov/upload:
+          file: coverage.xml
+      - store_test_results:
+          path: test-results
+
+  py37:
+    <<: *test-template
+    docker:
+      - image: mopidy/ci-python:3.7
+
+  black: *test-template
+
+  check-manifest: *test-template
+
+  flake8: *test-template
diff --git a/.gitignore b/.gitignore
index 1e3323d..03640c9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,8 +1,9 @@
-*.egg-info
 *.pyc
-.coverage
-.pytest_cache/
-.tox
-MANIFEST
-build/
-dist/
+/.coverage
+/.mypy_cache/
+/.pytest_cache/
+/.tox/
+/*.egg-info
+/build/
+/dist/
+/MANIFEST
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 02d2108..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,34 +0,0 @@
----
-sudo: false
-dist: trusty
-
-language: python
-
-python:
-  - '2.7'
-
-env:
-  - TOX_ENV=py27
-  - TOX_ENV=flake8
-
-install:
-  - pip install tox
-
-script:
-  - tox -e $TOX_ENV
-
-after_success:
-  - if [ $TOX_ENV == 'py27' ]; then pip install coveralls; coveralls; fi
-
-branches:
-  except:
-    - debian
-
-notifications:
-  irc:
-    channels:
-      - irc.freenode.org#mopidy
-    on_success: change
-    on_failure: change
-    use_notice: true
-    skip_join: true
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
new file mode 100644
index 0000000..b295a43
--- /dev/null
+++ b/CHANGELOG.rst
@@ -0,0 +1,128 @@
+*********
+Changelog
+*********
+
+v2.1.0 (2018-05-30)
+===================
+
+- Fix ``AttributeError: 'list' object has no attribute 'name'`` when browsing
+  tracks. (Fixes #43, #45, #59, PR #69)
+- Improved error handling. (Fixes #53, #71, #90, #95, PR #100)
+- Merged oustanding pull requests implementing various API updates. (Fixes #79,
+  #82, PR #100)
+- Cached main API endpoint responses for 10 seconds.
+- Cached stream links to reduce impact of API rate limit. (PR #100)
+- Add ``explore_songs`` config to limit the number of results returned.
+  (PR #100)
+
+v2.0.2 (2016-01-03)
+===================
+
+- Handle HTTP connection errors without a response. (PR #61)
+
+- Ignore tracks without an URI. (Related to mopidy#1340, PR #62)
+
+v2.0.1 (2015-10-06)
+===================
+
+- Fix Unicode escape sequences in SoundCloud search queries by encoding as
+  UTF-8. (Fixes #42, PR #55)
+
+v2.0.0 (2015-03-25)
+===================
+
+- Require Mopidy >= 1.0.
+
+- Update to work with new playback API in Mopidy 1.0.
+
+- Update to work with new backend search API in Mopidy 1.0.
+
+v1.2.5 (2014-06-24)
+===================
+
+- Add support for new explore api
+
+v1.2.4 (2014-05-15)
+===================
+
+- Add support for adding track by url
+- Fix search parsing
+- Support for adding playlists from liked section
+- Fix for track parsing and empty artists field
+
+v1.2.3 (2014-04-02)
+===================
+
+- Add support for playing music from groups
+
+v1.2.2 (2014-03-26)
+===================
+
+- Update Soundcloud API endpoint
+
+v1.2.1 (2014-02-21)
+===================
+
+- Properly escape unsafe chars in URIs.
+
+v1.2.0 (2014-02-16)
+===================
+
+- Deprecated ``explore`` and ``explore_pages`` config values.
+
+- Extension is now using Mopidy's virtual filesystem to expose music from your
+  SoundCloud account instead of fake playlists. See the "Browse" or "Files"
+  option in your MPD client.
+
+  In the virtual file system you can browse:
+
+  - The "Stream" with tracks from the users you follow.
+
+  - All "Explore" sections.
+
+  - Your followers and their shared tracks.
+
+  - Your liked tracks.
+
+  - Your sets.
+
+- Add search support.
+
+- Add support for looking up music by SoundCloud URLs through searching for the
+  URL as a file name.
+
+v1.1.0 (2014-01-20)
+===================
+
+- Updated extension and backend APIs to match Mopidy 0.18.
+
+v1.0.18 (2014-01-11)
+====================
+
+- Use proper logger namespaced to ``mopidy_soundcloud`` instead of ``mopidy``.
+
+- Fix wrong use of ``raise`` when the SoundCloud API doesn't respond as
+  expected.
+
+v1.0.17 (2013-12-21)
+====================
+
+- Don't cache the user request.
+
+- Require Requests >= 2.0. (Fixes #3)
+
+v1.0.16 (2013-10-22)
+====================
+
+- Require Mopidy >= 0.14.
+
+- Fix crash when SoundCloud returns 404 on track lookup. (Fixes #7)
+
+- Add some tests.
+
+v1.0.15 (2013-07-31)
+====================
+
+- Import code from old repo.
+
+- Handle authentication errors without crashing. (Fixes #3 and #4)
diff --git a/MANIFEST.in b/MANIFEST.in
index a4c2bdd..a4203b7 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,11 +1,16 @@
-include .coveragerc
+include *.py
+include *.rst
 include .mailmap
-include .travis.yml
 include LICENSE
 include MANIFEST.in
-include README.rst
-include mopidy_soundcloud/ext.conf
+include pyproject.toml
 include tox.ini
 
+recursive-include .circleci *
+recursive-include .github *
+
+include mopidy_*/ext.conf
+
 recursive-include tests *.py
-recursive-include tests *.yaml
+recursive-include tests/data *
+recursive-include tests/fixtures *.yaml
diff --git a/README.rst b/README.rst
index cff617c..934ac26 100644
--- a/README.rst
+++ b/README.rst
@@ -2,20 +2,20 @@
 Mopidy-SoundCloud
 *****************
 
-.. image:: https://img.shields.io/pypi/v/Mopidy-SoundCloud.svg?style=flat
-    :target: https://pypi.python.org/pypi/Mopidy-SoundCloud/
+.. image:: https://img.shields.io/pypi/v/Mopidy-SoundCloud
+    :target: https://pypi.org/project/Mopidy-SoundCloud/
     :alt: Latest PyPI version
 
-.. image:: https://img.shields.io/travis/mopidy/mopidy-soundcloud/master.svg?style=flat
-    :target: https://travis-ci.org/mopidy/mopidy-soundcloud
-    :alt: Travis CI build status
+.. image:: https://img.shields.io/circleci/build/gh/mopidy/mopidy-soundcloud
+    :target: https://circleci.com/gh/mopidy/mopidy-soundcloud
+    :alt: CircleCI build status
 
-.. image:: https://img.shields.io/coveralls/mopidy/mopidy-soundcloud/master.svg?style=flat
-   :target: https://coveralls.io/r/mopidy/mopidy-soundcloud?branch=master
-   :alt: Test coverage
+.. image:: https://img.shields.io/codecov/c/gh/mopidy/mopidy-soundcloud
+    :target: https://codecov.io/gh/mopidy/mopidy-soundcloud
+    :alt: Test coverage
 
-`Mopidy <http://www.mopidy.com/>`_ extension for playing music from
-`SoundCloud <http://www.soundcloud.com>`_.
+`Mopidy <https://mopidy.com/>`_ extension for playing music from
+`SoundCloud <https://soundcloud.com>`_.
 
 
 Maintainer wanted
@@ -43,9 +43,9 @@ Installation
 ============
 
 Debian/Ubuntu/Raspbian: Install the ``mopidy-soundcloud`` package from
-`apt.mopidy.com <http://apt.mopidy.com/>`_::
+`apt.mopidy.com <https://apt.mopidy.com/>`_::
 
-    sudo apt-get install mopidy-soundcloud
+    sudo apt install mopidy-soundcloud
 
 Arch Linux: Install the ``mopidy-soundcloud`` package from
 `AUR <https://aur.archlinux.org/packages/mopidy-soundcloud/>`_::
@@ -60,7 +60,7 @@ OS X: Install the ``mopidy-soundcloud`` package from the
 Else: Install the dependencies listed above yourself, and then install the
 package from PyPI::
 
-    pip install Mopidy-SoundCloud
+    python3 -m pip install Mopidy-SoundCloud
 
 If you're having trouble with audio playback from SoundCloud, make sure you
 have the "ugly" plugin set from GStreamer installed for MP3 support. The
@@ -72,10 +72,10 @@ core, so you may be missing it.
 Configuration
 =============
 
-#. You must register for a user account at http://www.soundcloud.com/
+#. You must register for a user account at https://soundcloud.com/
 
 #. You need a SoundCloud authentication token for Mopidy from
-   http://www.mopidy.com/authenticate
+   https://mopidy.com/authenticate
 
 #. Add the authentication token to the ``mopidy.conf`` config file::
 
@@ -91,6 +91,7 @@ Project resources
 
 - `Source code <https://github.com/mopidy/mopidy-soundcloud>`_
 - `Issue tracker <https://github.com/mopidy/mopidy-soundcloud/issues>`_
+- `Changelog <https://github.com/mopidy/mopidy-soundcloud/blob/master/CHANGELOG.rst>`_
 
 
 Credits
@@ -99,132 +100,3 @@ Credits
 - Original author: `Janez Troha <https://github.com/dz0ny>`_
 - Current maintainer: None. Maintainer wanted, see section above.
 - `Contributors <https://github.com/mopidy/mopidy-soundcloud/graphs/contributors>`_
-
-
-Changelog
-=========
-
-v2.1.0 (2018-05-30)
--------------------
-
-- Fix ``AttributeError: 'list' object has no attribute 'name'`` when browsing
-  tracks. (Fixes #43, #45, #59, PR #69)
-- Improved error handling. (Fixes #53, #71, #90, #95, PR #100)
-- Merged oustanding pull requests implementing various API updates. (Fixes #79,
-  #82, PR #100)
-- Cached main API endpoint responses for 10 seconds.
-- Cached stream links to reduce impact of API rate limit. (PR #100)
-- Add ``explore_songs`` config to limit the number of results returned.
-  (PR #100)
-
-v2.0.2 (2016-01-03)
--------------------
-
-- Handle HTTP connection errors without a response. (PR #61)
-
-- Ignore tracks without an URI. (Related to mopidy#1340, PR #62)
-
-v2.0.1 (2015-10-06)
--------------------
-
-- Fix Unicode escape sequences in SoundCloud search queries by encoding as
-  UTF-8. (Fixes #42, PR #55)
-
-v2.0.0 (2015-03-25)
--------------------
-
-- Require Mopidy >= 1.0.
-
-- Update to work with new playback API in Mopidy 1.0.
-
-- Update to work with new backend search API in Mopidy 1.0.
-
-v1.2.5 (2014-06-24)
--------------------
-
-- Add support for new explore api
-
-v1.2.4 (2014-05-15)
--------------------
-
-- Add support for adding track by url
-- Fix search parsing
-- Support for adding playlists from liked section
-- Fix for track parsing and empty artists field
-
-v1.2.3 (2014-04-02)
--------------------
-
-- Add support for playing music from groups
-
-v1.2.2 (2014-03-26)
--------------------
-
-- Update Soundcloud API endpoint
-
-v1.2.1 (2014-02-21)
--------------------
-
-- Properly escape unsafe chars in URIs.
-
-v1.2.0 (2014-02-16)
--------------------
-
-- Deprecated ``explore`` and ``explore_pages`` config values.
-
-- Extension is now using Mopidy's virtual filesystem to expose music from your
-  SoundCloud account instead of fake playlists. See the "Browse" or "Files"
-  option in your MPD client.
-
-  In the virtual file system you can browse:
-
-  - The "Stream" with tracks from the users you follow.
-
-  - All "Explore" sections.
-
-  - Your followers and their shared tracks.
-
-  - Your liked tracks.
-
-  - Your sets.
-
-- Add search support.
-
-- Add support for looking up music by SoundCloud URLs through searching for the
-  URL as a file name.
-
-v1.1.0 (2014-01-20)
--------------------
-
-- Updated extension and backend APIs to match Mopidy 0.18.
-
-v1.0.18 (2014-01-11)
---------------------
-
-- Use proper logger namespaced to ``mopidy_soundcloud`` instead of ``mopidy``.
-
-- Fix wrong use of ``raise`` when the SoundCloud API doesn't respond as
-  expected.
-
-v1.0.17 (2013-12-21)
---------------------
-
-- Don't cache the user request.
-
-- Require Requests >= 2.0. (Fixes #3)
-
-v1.0.16 (2013-10-22)
---------------------
-
-- Require Mopidy >= 0.14.
-
-- Fix crash when SoundCloud returns 404 on track lookup. (Fixes #7)
-
-- Add some tests.
-
-v1.0.15 (2013-07-31)
---------------------
-
-- Import code from old repo.
-
-- Handle authentication errors without crashing. (Fixes #3 and #4)
diff --git a/mopidy_soundcloud/__init__.py b/mopidy_soundcloud/__init__.py
index 9680969..ebe51a9 100644
--- a/mopidy_soundcloud/__init__.py
+++ b/mopidy_soundcloud/__init__.py
@@ -1,41 +1,41 @@
-from __future__ import unicode_literals
+import pathlib
 
-import os
+import pkg_resources
 
 from mopidy import config, ext
 from mopidy.exceptions import ExtensionError
 
+__version__ = pkg_resources.get_distribution("Mopidy-SoundCloud").version
 
-__version__ = '2.1.0'
 
+class Extension(ext.Extension):
 
-class SoundCloudExtension(ext.Extension):
-
-    dist_name = 'Mopidy-SoundCloud'
-    ext_name = 'soundcloud'
+    dist_name = "Mopidy-SoundCloud"
+    ext_name = "soundcloud"
     version = __version__
 
     def get_default_config(self):
-        conf_file = os.path.join(os.path.dirname(__file__), 'ext.conf')
-        return config.read(conf_file)
+        return config.read(pathlib.Path(__file__).parent / "ext.conf")
 
     def get_config_schema(self):
-        schema = super(SoundCloudExtension, self).get_config_schema()
-        schema['explore_songs'] = config.Integer(optional=True)
-        schema['auth_token'] = config.Secret()
-        schema['explore'] = config.Deprecated()
-        schema['explore_pages'] = config.Deprecated()
+        schema = super().get_config_schema()
+        schema["explore_songs"] = config.Integer(optional=True)
+        schema["auth_token"] = config.Secret()
+        schema["explore"] = config.Deprecated()
+        schema["explore_pages"] = config.Deprecated()
         return schema
 
     def validate_config(self, config):  # no_coverage
-        if not config.getboolean('soundcloud', 'enabled'):
+        if not config.getboolean("soundcloud", "enabled"):
             return
-        if not config.get('soundcloud', 'auth_token'):
+        if not config.get("soundcloud", "auth_token"):
             raise ExtensionError(
-                'In order to use SoundCloud extension you must provide an '
-                'auth token. For more information refer to '
-                'https://github.com/mopidy/mopidy-soundcloud/')
+                "In order to use SoundCloud extension you must provide an "
+                "auth token. For more information refer to "
+                "https://github.com/mopidy/mopidy-soundcloud/"
+            )
 
     def setup(self, registry):
         from .actor import SoundCloudBackend
-        registry.add('backend', SoundCloudBackend)
+
+        registry.add("backend", SoundCloudBackend)
diff --git a/mopidy_soundcloud/actor.py b/mopidy_soundcloud/actor.py
index a3bdc88..9f9a45d 100644
--- a/mopidy_soundcloud/actor.py
+++ b/mopidy_soundcloud/actor.py
@@ -1,37 +1,31 @@
-from __future__ import unicode_literals
-
 import logging
 
-from mopidy import backend
-
 import pykka
 
-from .library import SoundCloudLibraryProvider
-from .soundcloud import SoundCloudClient
-
+from mopidy import backend
+from mopidy_soundcloud.library import SoundCloudLibraryProvider
+from mopidy_soundcloud.soundcloud import SoundCloudClient
 
 logger = logging.getLogger(__name__)
 
 
 class SoundCloudBackend(pykka.ThreadingActor, backend.Backend):
-
     def __init__(self, config, audio):
-        super(SoundCloudBackend, self).__init__()
+        super().__init__()
         self.config = config
         self.remote = SoundCloudClient(config)
         self.library = SoundCloudLibraryProvider(backend=self)
         self.playback = SoundCloudPlaybackProvider(audio=audio, backend=self)
 
-        self.uri_schemes = ['soundcloud', 'sc']
+        self.uri_schemes = ["soundcloud", "sc"]
 
     def on_start(self):
-        username = self.remote.user.get('username')
+        username = self.remote.user.get("username")
         if username is not None:
             logger.info('Logged in to SoundCloud as "%s"', username)
 
 
 class SoundCloudPlaybackProvider(backend.PlaybackProvider):
-
     def translate_uri(self, uri):
         track_id = self.backend.remote.parse_track_uri(uri)
         track = self.backend.remote.get_track(track_id, True)
diff --git a/mopidy_soundcloud/library.py b/mopidy_soundcloud/library.py
index 5a01409..e7dbf1f 100644
--- a/mopidy_soundcloud/library.py
+++ b/mopidy_soundcloud/library.py
@@ -1,29 +1,21 @@
-from __future__ import unicode_literals
-
 import collections
 import logging
 import re
-import urllib
-from urlparse import urlparse
+import urllib.parse
 
 from mopidy import backend, models
 from mopidy.models import SearchResult, Track
-
 from mopidy_soundcloud.soundcloud import safe_url
 
-
 logger = logging.getLogger(__name__)
 
 
 def generate_uri(path):
-    return 'soundcloud:directory:%s' % urllib.quote('/'.join(path))
+    return "soundcloud:directory:%s" % urllib.parse.quote("/".join(path))
 
 
 def new_folder(name, path):
-    return models.Ref.directory(
-        uri=generate_uri(path),
-        name=safe_url(name)
-    )
+    return models.Ref.directory(uri=generate_uri(path), name=safe_url(name))
 
 
 def simplify_search_query(query):
@@ -35,71 +27,70 @@ def simplify_search_query(query):
                 r.extend(v)
             else:
                 r.append(v)
-        return ' '.join(r)
+        return " ".join(r)
     if isinstance(query, list):
-        return ' '.join(query)
+        return " ".join(query)
     else:
         return query
 
 
 class SoundCloudLibraryProvider(backend.LibraryProvider):
     root_directory = models.Ref.directory(
-        uri='soundcloud:directory',
-        name='SoundCloud'
+        uri="soundcloud:directory", name="SoundCloud"
     )
 
     def __init__(self, *args, **kwargs):
-        super(SoundCloudLibraryProvider, self).__init__(*args, **kwargs)
-        self.vfs = {'soundcloud:directory': collections.OrderedDict()}
-        self.add_to_vfs(new_folder('Following', ['following']))
-        self.add_to_vfs(new_folder('Liked', ['liked']))
-        self.add_to_vfs(new_folder('Sets', ['sets']))
-        self.add_to_vfs(new_folder('Stream', ['stream']))
+        super().__init__(*args, **kwargs)
+        self.vfs = {"soundcloud:directory": {}}
+        self.add_to_vfs(new_folder("Following", ["following"]))
+        self.add_to_vfs(new_folder("Liked", ["liked"]))
+        self.add_to_vfs(new_folder("Sets", ["sets"]))
+        self.add_to_vfs(new_folder("Stream", ["stream"]))
 
     def add_to_vfs(self, _model):
-        self.vfs['soundcloud:directory'][_model.uri] = _model
+        self.vfs["soundcloud:directory"][_model.uri] = _model
 
     def list_sets(self):
         sets_vfs = collections.OrderedDict()
-        for (name, set_id, tracks) in self.backend.remote.get_sets():
-            sets_list = new_folder(name, ['sets', set_id])
-            logger.debug('Adding set %s to vfs' % sets_list.name)
+        for (name, set_id, _tracks) in self.backend.remote.get_sets():
+            sets_list = new_folder(name, ["sets", set_id])
+            logger.debug("Adding set %s to vfs" % sets_list.name)
             sets_vfs[set_id] = sets_list
-        return sets_vfs.values()
+        return list(sets_vfs.values())
 
     def list_liked(self):
         vfs_list = collections.OrderedDict()
         for track in self.backend.remote.get_likes():
-            logger.debug('Adding liked track %s to vfs' % track.name)
+            logger.debug("Adding liked track %s to vfs" % track.name)
             vfs_list[track.name] = models.Ref.track(
-                uri=track.uri, name=track.name)
-        return vfs_list.values()
+                uri=track.uri, name=track.name
+            )
+        return list(vfs_list.values())
 
     def list_user_follows(self):
         sets_vfs = collections.OrderedDict()
         for (name, user_id) in self.backend.remote.get_followings():
-            sets_list = new_folder(name, ['following', user_id])
-            logger.debug('Adding set %s to vfs' % sets_list.name)
+            sets_list = new_folder(name, ["following", user_id])
+            logger.debug("Adding set %s to vfs" % sets_list.name)
             sets_vfs[user_id] = sets_list
-        return sets_vfs.values()
+        return list(sets_vfs.values())
 
     def tracklist_to_vfs(self, track_list):
         vfs_list = collections.OrderedDict()
         for temp_track in track_list:
             if not isinstance(temp_track, Track):
                 temp_track = self.backend.remote.parse_track(temp_track)
-            if hasattr(temp_track, 'uri'):
+            if hasattr(temp_track, "uri"):
                 vfs_list[temp_track.name] = models.Ref.track(
-                    uri=temp_track.uri,
-                    name=temp_track.name
+                    uri=temp_track.uri, name=temp_track.name
                 )
-        return vfs_list.values()
+        return list(vfs_list.values())
 
     def browse(self, uri):
         if not self.vfs.get(uri):
-            (req_type, res_id) = re.match(r'.*:(\w*)(?:/(\d*))?', uri).groups()
+            (req_type, res_id) = re.match(r".*:(\w*)(?:/(\d*))?", uri).groups()
             # Sets
-            if 'sets' == req_type:
+            if "sets" == req_type:
                 if res_id:
                     return self.tracklist_to_vfs(
                         self.backend.remote.get_set(res_id)
@@ -107,7 +98,7 @@ class SoundCloudLibraryProvider(backend.LibraryProvider):
                 else:
                     return self.list_sets()
             # Following
-            if 'following' == req_type:
+            if "following" == req_type:
                 if res_id:
                     return self.tracklist_to_vfs(
                         self.backend.remote.get_tracks(res_id)
@@ -115,16 +106,16 @@ class SoundCloudLibraryProvider(backend.LibraryProvider):
                 else:
                     return self.list_user_follows()
             # Liked
-            if 'liked' == req_type:
+            if "liked" == req_type:
                 return self.list_liked()
             # User stream
-            if 'stream' == req_type:
+            if "stream" == req_type:
                 return self.tracklist_to_vfs(
                     self.backend.remote.get_user_stream()
                 )
 
         # root directory
-        return self.vfs.get(uri, {}).values()
+        return list(self.vfs.get(uri, {}).values())
 
     def search(self, query=None, uris=None, exact=False):
         # TODO Support exact search
@@ -132,26 +123,26 @@ class SoundCloudLibraryProvider(backend.LibraryProvider):
         if not query:
             return
 
-        if 'uri' in query:
-            search_query = ''.join(query['uri'])
-            url = urlparse(search_query)
-            if 'soundcloud.com' in url.netloc:
-                logger.info('Resolving SoundCloud for: %s', search_query)
+        if "uri" in query:
+            search_query = "".join(query["uri"])
+            url = urllib.parse.urlparse(search_query)
+            if "soundcloud.com" in url.netloc:
+                logger.info("Resolving SoundCloud for: %s", search_query)
                 return SearchResult(
-                    uri='soundcloud:search',
-                    tracks=self.backend.remote.resolve_url(search_query)
+                    uri="soundcloud:search",
+                    tracks=self.backend.remote.resolve_url(search_query),
                 )
         else:
             search_query = simplify_search_query(query)
-            logger.info('Searching SoundCloud for: %s', search_query)
+            logger.info("Searching SoundCloud for: %s", search_query)
             return SearchResult(
-                uri='soundcloud:search',
-                tracks=self.backend.remote.search(search_query)
+                uri="soundcloud:search",
+                tracks=self.backend.remote.search(search_query),
             )
 
     def lookup(self, uri):
-        if 'sc:' in uri:
-            uri = uri.replace('sc:', '')
+        if "sc:" in uri:
+            uri = uri.replace("sc:", "")
             return self.backend.remote.resolve_url(uri)
 
         try:
@@ -159,9 +150,10 @@ class SoundCloudLibraryProvider(backend.LibraryProvider):
             track = self.backend.remote.get_track(track_id)
             if track is None:
                 logger.info(
-                    'Failed to lookup %s: SoundCloud track not found' % uri)
+                    "Failed to lookup %s: SoundCloud track not found" % uri
+                )
                 return []
             return [track]
         except Exception as error:
-            logger.error('Failed to lookup %s: %s', uri, error)
+            logger.error("Failed to lookup %s: %s", uri, error)
             return []
diff --git a/mopidy_soundcloud/soundcloud.py b/mopidy_soundcloud/soundcloud.py
index b0370ae..20a3c7b 100644
--- a/mopidy_soundcloud/soundcloud.py
+++ b/mopidy_soundcloud/soundcloud.py
@@ -1,5 +1,3 @@
-from __future__ import unicode_literals
-
 import collections
 import datetime
 import logging
@@ -9,43 +7,44 @@ import time
 import unicodedata
 from contextlib import closing
 from multiprocessing.pool import ThreadPool
-from urllib import quote_plus
-
-from mopidy import httpclient
-from mopidy.models import Album, Artist, Track
+from urllib.parse import quote_plus
 
 import requests
 from requests.adapters import HTTPAdapter
 from requests.exceptions import HTTPError
 
 import mopidy_soundcloud
-
+from mopidy import httpclient
+from mopidy.models import Album, Artist, Track
 
 logger = logging.getLogger(__name__)
 
 
 def safe_url(uri):
     return quote_plus(
-        unicodedata.normalize('NFKD', unicode(uri)).encode('ASCII', 'ignore'))
+        unicodedata.normalize("NFKD", uri).encode("ASCII", "ignore")
+    )
 
 
 def readable_url(uri):
-    valid_chars = "-_.() %s%s" % (string.ascii_letters, string.digits)
-    safe_uri = unicodedata.normalize('NFKD', unicode(uri)).encode('ASCII',
-                                                                  'ignore')
-    return re.sub(r'\s+', ' ',
-                  ''.join(c for c in safe_uri if c in valid_chars)).strip()
+    valid_chars = f"-_.() {string.ascii_letters}{string.digits}"
+    safe_uri = (
+        unicodedata.normalize("NFKD", uri).encode("ascii", "ignore").decode()
+    )
+    return re.sub(
+        r"\s+", " ", "".join(c for c in safe_uri if c in valid_chars)
+    ).strip()
 
 
 def streamble_url(url, client_id):
-    return '%s?client_id=%s' % (url, client_id)
+    return f"{url}?client_id={client_id}"
 
 
 def get_user_url(user_id):
     if not user_id:
-        return 'me'
+        return "me"
     else:
-        return 'users/%s' % user_id
+        return "users/%s" % user_id
 
 
 def get_requests_session(proxy_config, user_agent, token):
@@ -53,14 +52,14 @@ def get_requests_session(proxy_config, user_agent, token):
     full_user_agent = httpclient.format_user_agent(user_agent)
 
     session = requests.Session()
-    session.proxies.update({'http': proxy, 'https': proxy})
-    session.headers.update({'user-agent': full_user_agent})
-    session.headers.update({'Authorization': 'OAuth %s' % token})
+    session.proxies.update({"http": proxy, "https": proxy})
+    session.headers.update({"user-agent": full_user_agent})
+    session.headers.update({"Authorization": "OAuth %s" % token})
 
     return session
 
 
-class cache(object):
+class cache:  # noqa
     # TODO: merge this to util library
 
     def __init__(self, ctl=8, ttl=3600):
@@ -95,14 +94,15 @@ class cache(object):
 
 
 class ThrottlingHttpAdapter(HTTPAdapter):
-
     def __init__(self, burst_length, burst_window, wait_window):
-        super(ThrottlingHttpAdapter, self).__init__()
+        super().__init__()
         self.max_hits = burst_length
         self.hits = 0
         self.rate = burst_length / burst_window
         self.burst_window = datetime.timedelta(seconds=burst_window)
-        self.total_window = datetime.timedelta(seconds=burst_window + wait_window)
+        self.total_window = datetime.timedelta(
+            seconds=burst_window + wait_window
+        )
         self.timestamp = datetime.datetime.min
 
     def _is_too_many_requests(self):
@@ -110,10 +110,17 @@ class ThrottlingHttpAdapter(HTTPAdapter):
         if now < self.timestamp + self.total_window:
             elapsed = now - self.timestamp
             self.hits += 1
-            if (now < self.timestamp + self.burst_window) and (self.hits < self.max_hits):
+            if (now < self.timestamp + self.burst_window) and (
+                self.hits < self.max_hits
+            ):
                 return False
             else:
-                logger.debug('Request throttling after %u hits in %u us (window until %s)', self.hits, elapsed.microseconds, self.timestamp + self.total_window)
+                logger.debug(
+                    "Request throttling after %u hits in %u us (window until %s)",
+                    self.hits,
+                    elapsed.microseconds,
+                    self.timestamp + self.total_window,
+                )
                 return True
         else:
             self.timestamp = now
@@ -121,50 +128,56 @@ class ThrottlingHttpAdapter(HTTPAdapter):
             return False
 
     def send(self, request, **kwargs):
-        if request.method == 'HEAD' and self._is_too_many_requests():
+        if request.method == "HEAD" and self._is_too_many_requests():
             resp = requests.Response()
             resp.request = request
             resp.url = request.url
             resp.status_code = 429
-            resp.reason = 'Client throttled to %u requests per second' % self.rate
+            resp.reason = (
+                "Client throttled to %u requests per second" % self.rate
+            )
             return resp
         else:
-            return super(ThrottlingHttpAdapter, self).send(request, **kwargs)
+            return super().send(request, **kwargs)
 
 
-class SoundCloudClient(object):
-    CLIENT_ID = '93e33e327fd8a9b77becd179652272e2'
+class SoundCloudClient:
+    CLIENT_ID = "93e33e327fd8a9b77becd179652272e2"
 
     def __init__(self, config):
-        super(SoundCloudClient, self).__init__()
-        self.explore_songs = config['soundcloud'].get('explore_songs', 25)
+        super().__init__()
+        self.explore_songs = config["soundcloud"].get("explore_songs", 25)
         self.http_client = get_requests_session(
-            proxy_config=config['proxy'],
-            user_agent='%s/%s' % (
-                mopidy_soundcloud.SoundCloudExtension.dist_name,
-                mopidy_soundcloud.__version__),
-            token=config['soundcloud']['auth_token'])
-        adapter = ThrottlingHttpAdapter(burst_length=3, burst_window=1, wait_window=10)
-        self.http_client.mount('https://api.soundcloud.com/', adapter)
+            proxy_config=config["proxy"],
+            user_agent="{}/{}".format(
+                mopidy_soundcloud.Extension.dist_name,
+                mopidy_soundcloud.__version__,
+            ),
+            token=config["soundcloud"]["auth_token"],
+        )
+        adapter = ThrottlingHttpAdapter(
+            burst_length=3, burst_window=1, wait_window=10
+        )
+        self.http_client.mount("https://api.soundcloud.com/", adapter)
 
     @property
     @cache()
     def user(self):
-        return self._get('me')
+        return self._get("me")
 
     @cache(ttl=10)
     def get_user_stream(self):
         # https://developers.soundcloud.com/docs/api/reference#activities
         tracks = []
-        stream = self._get('me/activities', limit=True).get('collection', [])
+        stream = self._get("me/activities", limit=True).get("collection", [])
         for data in stream:
-            kind = data.get('origin')
+            kind = data.get("origin")
             # multiple types of track with same data
             if kind:
-                if kind['kind'] == 'track':
+                if kind["kind"] == "track":
                     tracks.append(self.parse_track(kind))
-                elif kind['kind'] == 'playlist':
-                    playlist = kind.get('tracks')
+                elif kind["kind"] == "playlist":
+                    playlist = kind.get("tracks")
                     if isinstance(playlist, collections.Iterable):
                         tracks.extend(self.parse_results(playlist))
 
@@ -174,31 +187,32 @@ class SoundCloudClient(object):
     def get_followings(self, user_id=None):
         user_url = get_user_url(user_id)
         users = []
-        playlists = self._get('%s/followings' % user_url, limit=True)
-        for playlist in playlists.get('collection', []):
-            user_name = playlist.get('username')
-            user_id = str(playlist.get('id'))
-            logger.debug('Fetched user %s with id %s' % (user_name, user_id))
+        playlists = self._get("%s/followings" % user_url, limit=True)
+        for playlist in playlists.get("collection", []):
+            user_name = playlist.get("username")
+            user_id = str(playlist.get("id"))
+            logger.debug(f"Fetched user {user_name} with id {user_id}")
             users.append((user_name, user_id))
         return users
 
     @cache()
     def get_set(self, set_id):
         # https://developers.soundcloud.com/docs/api/reference#playlists
-        playlist = self._get('playlists/%s' % set_id)
-        return playlist.get('tracks', [])
+        playlist = self._get("playlists/%s" % set_id)
+        return playlist.get("tracks", [])
 
     @cache(ttl=10)
     def get_sets(self, user_id=None):
         user_url = get_user_url(user_id)
         playable_sets = []
-        for playlist in self._get('%s/playlists' % user_url, limit=True):
-            name = playlist.get('title')
-            set_id = str(playlist.get('id'))
-            tracks = playlist.get('tracks', [])
-            logger.debug('Fetched set %s with id %s (%d tracks)' % (
-                name, set_id, len(tracks)
-            ))
+        for playlist in self._get("%s/playlists" % user_url, limit=True):
+            name = playlist.get("title")
+            set_id = str(playlist.get("id"))
+            tracks = playlist.get("tracks", [])
+            logger.debug(
+                "Fetched set %s with id %s (%d tracks)"
+                % (name, set_id, len(tracks))
+            )
             playable_sets.append((name, set_id, tracks))
         return playable_sets
 
@@ -206,35 +220,36 @@ class SoundCloudClient(object):
     def get_likes(self, user_id=None):
         # https://developers.soundcloud.com/docs/api/reference#GET--users--id--favorites
         user_url = get_user_url(user_id)
-        likes = self._get('%s/favorites' % user_url, limit=True)
+        likes = self._get("%s/favorites" % user_url, limit=True)
         return self.parse_results(likes)
 
     @cache(ttl=10)
     def get_tracks(self, user_id=None):
         user_url = get_user_url(user_id)
-        tracks = self._get('%s/tracks' % user_url, limit=True)
+        tracks = self._get("%s/tracks" % user_url, limit=True)
         return self.parse_results(tracks)
 
     # Public
     @cache()
     def get_track(self, track_id, streamable=False):
-        logger.debug('Getting info for track with id %s' % track_id)
+        logger.debug("Getting info for track with id %s" % track_id)
         try:
-            return self.parse_track(self._get('tracks/%s' % track_id),
-                                    streamable)
+            return self.parse_track(
+                self._get("tracks/%s" % track_id), streamable
+            )
         except Exception:
             return None
 
     def parse_track_uri(self, track):
-        logger.debug('Parsing track %s' % (track))
+        logger.debug("Parsing track %s" % (track))
         if hasattr(track, "uri"):
             track = track.uri
-        return track.split('.')[-1]
+        return track.split(".")[-1]
 
     def search(self, query):
         # https://developers.soundcloud.com/docs/api/reference#tracks
-        query = quote_plus(query.encode('utf-8'))
-        search_results = self._get('tracks?q=%s' % query, limit=True)
+        query = quote_plus(query.encode("utf-8"))
+        search_results = self._get("tracks?q=%s" % query, limit=True)
         tracks = []
         for track in search_results:
             tracks.append(self.parse_track(track, False))
@@ -242,100 +257,99 @@ class SoundCloudClient(object):
 
     def parse_results(self, res):
         tracks = []
-        logger.debug('Parsing %d result item(s)...', len(res))
+        logger.debug("Parsing %d result item(s)...", len(res))
         for item in res:
-            if item['kind'] == 'track':
+            if item["kind"] == "track":
                 tracks.append(self.parse_track(item))
-            elif item['kind'] == 'playlist':
-                playlist_tracks = item.get('tracks', [])
-                logger.debug('Parsing %u playlist track(s)...',
-                             len(playlist_tracks))
+            elif item["kind"] == "playlist":
+                playlist_tracks = item.get("tracks", [])
+                logger.debug(
+                    "Parsing %u playlist track(s)...", len(playlist_tracks)
+                )
                 for track in playlist_tracks:
                     tracks.append(self.parse_track(track))
             else:
-                logger.warning("Unknown item type '%s'.", item['kind'])
+                logger.warning("Unknown item type '%s'.", item["kind"])
         return self.sanitize_tracks(tracks)
 
     def resolve_url(self, uri):
-        return self.parse_results([self._get('resolve?url=%s' % uri)])
+        return self.parse_results([self._get("resolve?url=%s" % uri)])
 
     def _get(self, url, limit=None):
-        url = 'https://api.soundcloud.com/%s' % url
-        params = [('client_id', self.CLIENT_ID)]
+        url = "https://api.soundcloud.com/%s" % url
+        params = [("client_id", self.CLIENT_ID)]
         if limit:
-            params.insert(0, ('limit', self.explore_songs))
+            params.insert(0, ("limit", self.explore_songs))
         try:
             with closing(self.http_client.get(url, params=params)) as res:
-                logger.debug('Requested %s', res.url)
+                logger.debug("Requested %s", res.url)
                 res.raise_for_status()
                 return res.json()
         except Exception as e:
             if isinstance(e, HTTPError) and e.response.status_code == 401:
-                logger.error('Invalid "auth_token" used for SoundCloud '
-                             'authentication!')
+                logger.error(
+                    'Invalid "auth_token" used for SoundCloud '
+                    "authentication!"
+                )
             else:
-                logger.error('SoundCloud API request failed: %s' % e)
+                logger.error("SoundCloud API request failed: %s" % e)
         return {}
 
     def sanitize_tracks(self, tracks):
-        return filter(None, tracks)
+        return [t for t in tracks if t]
 
     @cache()
     def parse_track(self, data, remote_url=False):
         if not data:
             return None
-        if not data.get('streamable'):
+        if not data.get("streamable"):
             logger.info(
-                "'%s' can't be streamed from SoundCloud" % data.get('title'))
+                "'%s' can't be streamed from SoundCloud" % data.get("title")
+            )
             return None
-        if not data.get('kind') == 'track':
-            logger.debug('%s is not track' % data.get('title'))
+        if not data.get("kind") == "track":
+            logger.debug("%s is not track" % data.get("title"))
             return None
 
         track_kwargs = {}
         artist_kwargs = {}
         album_kwargs = {}
 
-        if 'title' in data:
-            label_name = data.get('label_name')
+        if "title" in data:
+            label_name = data.get("label_name")
             if not label_name:
-                label_name = data.get(
-                    'user', {}).get('username', 'Unknown label')
+                label_name = data.get("user", {}).get(
+                    "username", "Unknown label"
+                )
 
-            track_kwargs['name'] = data['title']
-            artist_kwargs['name'] = label_name
-            album_kwargs['name'] = 'SoundCloud'
+            track_kwargs["name"] = data["title"]
+            artist_kwargs["name"] = label_name
+            album_kwargs["name"] = "SoundCloud"
 
-        if 'date' in data:
-            track_kwargs['date'] = data['date']
+        if "date" in data:
+            track_kwargs["date"] = data["date"]
 
         if remote_url:
-            track_kwargs['uri'] = self.get_streamble_url(data['stream_url'])
-            if track_kwargs['uri'] is None:
-                logger.info("'%s' can't be streamed "
-                            "from SoundCloud" % data.get('title'))
+            track_kwargs["uri"] = self.get_streamble_url(data["stream_url"])
+            if track_kwargs["uri"] is None:
+                logger.info(
+                    "'%s' can't be streamed "
+                    "from SoundCloud" % data.get("title")
+                )
                 return None
         else:
-            track_kwargs['uri'] = 'soundcloud:song/%s.%s' % (
-                readable_url(data.get('title')), data.get('id'))
+            track_kwargs["uri"] = "soundcloud:song/{}.{}".format(
+                readable_url(data.get("title")), data.get("id")
+            )
 
-        track_kwargs['length'] = int(data.get('duration', 0))
-        track_kwargs['comment'] = data.get('permalink_url', '')
+        track_kwargs["length"] = int(data.get("duration", 0))
+        track_kwargs["comment"] = data.get("permalink_url", "")
 
         if artist_kwargs:
-            track_kwargs['artists'] = [Artist(**artist_kwargs)]
+            track_kwargs["artists"] = [Artist(**artist_kwargs)]
 
         if album_kwargs:
-            if data.get('artwork_url'):
-                album_kwargs['images'] = [data['artwork_url']]
-            else:
-                image = data.get('user', {}).get('avatar_url')
-                album_kwargs['images'] = [image]
-            if len(album_kwargs['images']) == 1:
-                image = album_kwargs['images'][0].replace('large', 't500x500')
-                album_kwargs['images'] = [image]
-
-            track_kwargs['album'] = Album(**album_kwargs)
+            track_kwargs["album"] = Album(**album_kwargs)
 
         return Track(**track_kwargs)
 
@@ -343,13 +357,13 @@ class SoundCloudClient(object):
     def get_streamble_url(self, url):
         req = self.http_client.head(streamble_url(url, self.CLIENT_ID))
         if req.status_code == 302:
-            return req.headers.get('Location', None)
+            return req.headers.get("Location", None)
         elif req.status_code == 429:
-            if req.reason != 'Unknown':
-                reason = ' (%s)' % req.reason
+            if req.reason != "Unknown":
+                reason = " (%s)" % req.reason
             else:
-                reason = ''
-            logger.warning('SoundCloud daily rate limit exceeded%s', reason)
+                reason = ""
+            logger.warning("SoundCloud daily rate limit exceeded%s", reason)
 
     def resolve_tracks(self, track_ids):
         """Resolve tracks concurrently emulating browser
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..bff16e0
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,17 @@
+[build-system]
+requires = ["setuptools >= 30.3.0", "wheel"]
+
+
+[tool.black]
+target-version = ["py37", "py38"]
+line-length = 80
+
+
+[tool.isort]
+multi_line_output = 3
+include_trailing_comma = true
+force_grid_wrap = 0
+use_parentheses = true
+line_length = 88
+known_tests = "tests"
+sections = "FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,TESTS,LOCALFOLDER"
diff --git a/setup.cfg b/setup.cfg
index f09dc60..95d1360 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,6 +1,89 @@
-[flake8]
-application-import-names = mopidy_soundcloud,tests
-exclude = .git,.tox
+[metadata]
+name = Mopidy-SoundCloud
+version = 2.1.0
+url = https://github.com/mopidy/mopidy-soundcloud
+author = Janez Troha
+author_email = dz0ny@ubuntu.si
+license = MIT
+license_file = LICENSE
+description = Mopidy extension for playing music from SoundCloud
+long_description = file: README.rst
+classifiers =
+    Environment :: No Input/Output (Daemon)
+    Intended Audience :: End Users/Desktop
+    License :: OSI Approved :: MIT License
+    Operating System :: OS Independent
+    Programming Language :: Python :: 3
+    Programming Language :: Python :: 3.7
+    Programming Language :: Python :: 3.8
+    Topic :: Multimedia :: Sound/Audio :: Players
+
+
+[options]
+zip_safe = False
+include_package_data = True
+packages = find:
+python_requires = >= 3.7
+install_requires =
+    Mopidy >= 3.0.0a5  # Change to >= 3.0 once final is released
+    Pykka >= 2.0.1
+    setuptools
+    requests >= 2.0.0
+
+
+[options.extras_require]
+lint =
+    black
+    check-manifest
+    flake8
+    flake8-bugbear
+    flake8-import-order
+    isort[pyproject]
+release =
+    twine
+    wheel
+test =
+    pytest
+    pytest-cov
+    vcrpy
+dev =
+    %(lint)s
+    %(release)s
+    %(test)s
+
+
+[options.packages.find]
+exclude =
+    tests
+    tests.*
 
-[wheel]
+
+[options.entry_points]
+mopidy.ext =
+    soundcloud = mopidy_soundcloud:Extension
+
+
+[bdist_wheel]
 universal = 1
+
+
+[flake8]
+application-import-names = mopidy_soundcloud, tests
+max-line-length = 80
+exclude = .git, .tox, build
+select =
+    # Regular flake8 rules
+    C, E, F, W
+    # flake8-bugbear rules
+    B
+    # B950: line too long (soft speed limit)
+    B950
+    # pep8-naming rules
+    N
+ignore =
+    # E203: whitespace before ':' (not PEP8 compliant)
+    E203
+    # E501: line too long (replaced by B950)
+    E501
+    # W503: line break before binary operator (not PEP8 compliant)
+    W503
diff --git a/setup.py b/setup.py
index 3739fb1..6068493 100644
--- a/setup.py
+++ b/setup.py
@@ -1,45 +1,3 @@
-from __future__ import unicode_literals
+from setuptools import setup
 
-import re
-
-from setuptools import find_packages, setup
-
-
-def get_version(filename):
-    with open(filename) as fh:
-        metadata = dict(re.findall("__([a-z]+)__ = '([^']+)'", fh.read()))
-        return metadata['version']
-
-
-setup(
-    name='Mopidy-SoundCloud',
-    version=get_version('mopidy_soundcloud/__init__.py'),
-    url='https://github.com/mopidy/mopidy-soundcloud',
-    license='MIT',
-    author='dz0ny',
-    author_email='dz0ny@ubuntu.si',
-    description='SoundCloud extension for Mopidy',
-    long_description=open('README.rst').read(),
-    packages=find_packages(exclude=['tests', 'tests.*']),
-    zip_safe=False,
-    include_package_data=True,
-    install_requires=[
-        'setuptools',
-        'Mopidy >= 1.0',
-        'Pykka >= 1.1',
-        'requests >= 2.0.0',
-    ],
-    entry_points={
-        'mopidy.ext': [
-            'soundcloud = mopidy_soundcloud:SoundCloudExtension',
-        ],
-    },
-    classifiers=[
-        'Environment :: No Input/Output (Daemon)',
-        'Intended Audience :: End Users/Desktop',
-        'License :: OSI Approved :: MIT License',
-        'Operating System :: OS Independent',
-        'Programming Language :: Python :: 2',
-        'Topic :: Multimedia :: Sound/Audio :: Players',
-    ],
-)
+setup()
diff --git a/tox.ini b/tox.ini
index d762a32..018ed4f 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,23 +1,23 @@
 [tox]
-envlist = py27, flake8
+envlist = py37, py38, black, check-manifest, flake8
 
 [testenv]
-deps =
-    mock
-    mopidy
-    pytest
-    pytest-cov
-    pytest-xdist
-    vcrpy
+sitepackages = true
+deps = .[test]
 commands =
-    py.test \
+    python -m pytest \
         --basetemp={envtmpdir} \
         --cov=mopidy_soundcloud --cov-report=term-missing \
         {posargs}
 
+[testenv:black]
+deps = .[lint]
+commands = python -m black --check .
+
+[testenv:check-manifest]
+deps = .[lint]
+commands = python -m check_manifest
+
 [testenv:flake8]
-deps =
-    flake8
-    flake8-import-order
-skip_install = true
-commands = flake8
+deps = .[lint]
+commands = python -m flake8 --show-source --statistics


Test patch:
diff --git a/tests/test_api.py b/tests/test_api.py
index b0492df..77643bf 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -1,199 +1,197 @@
-from __future__ import unicode_literals
-
 import os.path
 import unittest
-
-import mock
-
-from mopidy.models import Track
+from unittest import mock
 
 import vcr
 
 import mopidy_soundcloud
-from mopidy_soundcloud import SoundCloudExtension
+from mopidy.models import Track
+from mopidy_soundcloud import Extension
 from mopidy_soundcloud.soundcloud import SoundCloudClient, readable_url
 
 local_path = os.path.abspath(os.path.dirname(__file__))
-my_vcr = vcr.VCR(serializer='yaml',
-                 cassette_library_dir=local_path + '/fixtures',
-                 record_mode='once',
-                 match_on=['uri', 'method'],
-                 decode_compressed_response=False,
-                 filter_headers=['Authorization']
-                 )
+my_vcr = vcr.VCR(
+    serializer="yaml",
+    cassette_library_dir=local_path + "/fixtures",
+    record_mode="once",
+    match_on=["uri", "method"],
+    decode_compressed_response=False,
+    filter_headers=["Authorization"],
+)
 
 
 class ApiTest(unittest.TestCase):
-    @my_vcr.use_cassette('sc-login.yaml')
+    @my_vcr.use_cassette("sc-login.yaml")
     def setUp(self):
-        config = SoundCloudExtension().get_config_schema()
-        config['auth_token'] = '1-35204-61921957-55796ebef403996'
-        config['explore_songs'] = 10
-        self.api = SoundCloudClient({'soundcloud': config, 'proxy': {}})
+        config = Extension().get_config_schema()
+        config["auth_token"] = "1-35204-61921957-55796ebef403996"
+        config["explore_songs"] = 10
+        self.api = SoundCloudClient({"soundcloud": config, "proxy": {}})
 
     def test_sets_user_agent(self):
-        agent = 'Mopidy-SoundCloud/%s Mopidy/' % mopidy_soundcloud.__version__
-        self.assertTrue(agent in self.api.http_client.headers['user-agent'])
+        agent = "Mopidy-SoundCloud/%s Mopidy/" % mopidy_soundcloud.__version__
+        assert agent in self.api.http_client.headers["user-agent"]
 
     def test_resolves_string(self):
-        _id = self.api.parse_track_uri('soundcloud:song.38720262')
-        self.assertEquals(_id, '38720262')
+        _id = self.api.parse_track_uri("soundcloud:song.38720262")
+        assert _id == "38720262"
 
-    @my_vcr.use_cassette('sc-login-error.yaml')
+    @my_vcr.use_cassette("sc-login-error.yaml")
     def test_responds_with_error(self):
-        with mock.patch('mopidy_soundcloud.soundcloud.logger.error') as d:
-            config = SoundCloudExtension().get_config_schema()
-            config['auth_token'] = '1-fake-token'
-            SoundCloudClient({'soundcloud': config, 'proxy': {}}).user
-            d.assert_called_once_with('Invalid "auth_token" used for '
-                                      'SoundCloud authentication!')
-
-    @my_vcr.use_cassette('sc-login.yaml')
+        with mock.patch("mopidy_soundcloud.soundcloud.logger.error") as d:
+            config = Extension().get_config_schema()
+            config["auth_token"] = "1-fake-token"
+            SoundCloudClient({"soundcloud": config, "proxy": {}}).user
+            d.assert_called_once_with(
+                'Invalid "auth_token" used for ' "SoundCloud authentication!"
+            )
+
+    @my_vcr.use_cassette("sc-login.yaml")
     def test_returns_username(self):
-        user = self.api.user.get('username')
-        self.assertEquals(user, 'Nick Steel 3')
+        user = self.api.user.get("username")
+        assert user == "Nick Steel 3"
 
-    @my_vcr.use_cassette('sc-resolve-track.yaml')
+    @my_vcr.use_cassette("sc-resolve-track.yaml")
     def test_resolves_object(self):
         trackc = {}
-        trackc[b'uri'] = 'soundcloud:song.38720262'
+        trackc["uri"] = "soundcloud:song.38720262"
         track = Track(**trackc)
 
         id = self.api.parse_track_uri(track)
-        self.assertEquals(id, '38720262')
+        assert id == "38720262"
 
-    @my_vcr.use_cassette('sc-resolve-track-none.yaml')
+    @my_vcr.use_cassette("sc-resolve-track-none.yaml")
     def test_resolves_unknown_track_to_none(self):
-        track = self.api.get_track('s38720262')
-        self.assertIsNone(track)
-
-    @my_vcr.use_cassette('sc-resolve-track.yaml')
-    def test_resolves_Track(self):
-        track = self.api.get_track('13158665')
-        self.assertIsInstance(track, Track)
-        self.assertEquals(
-            track.uri,
-            'soundcloud:song/Munching at Tiannas house.13158665'
-        )
+        track = self.api.get_track("s38720262")
+        assert track is None
+
+    @my_vcr.use_cassette("sc-resolve-track.yaml")
+    def test_resolves_track(self):
+        track = self.api.get_track("13158665")
+        assert isinstance(track, Track)
+        assert track.uri == "soundcloud:song/Munching at Tiannas house.13158665"
 
-    @my_vcr.use_cassette('sc-resolve-http.yaml')
+    @my_vcr.use_cassette("sc-resolve-http.yaml")
     def test_resolves_http_url(self):
         track = self.api.resolve_url(
-            'https://soundcloud.com/bbc-radio-4/m-w-cloud'
+            "https://soundcloud.com/bbc-radio-4/m-w-cloud"
         )[0]
-        self.assertIsInstance(track, Track)
-        self.assertEquals(
-            track.uri,
-            'soundcloud:song/That Mitchell and Webb Sound The Cloud.122889665'
+        assert isinstance(track, Track)
+        assert (
+            track.uri
+            == "soundcloud:song/That Mitchell and Webb Sound The Cloud.122889665"
         )
 
-    @my_vcr.use_cassette('sc-resolve-set.yaml')
+    @my_vcr.use_cassette("sc-resolve-set.yaml")
     def test_resolves_set_url(self):
-        expected_tracks = ['01 Dash And Blast',
-                           '02 We Flood Empty Lakes',
-                           '03 A Song For Starlit Beaches',
-                           '04 Illuminate My Heart, My Darling']
+        expected_tracks = [
+            "01 Dash And Blast",
+            "02 We Flood Empty Lakes",
+            "03 A Song For Starlit Beaches",
+            "04 Illuminate My Heart, My Darling",
+        ]
         tracks = self.api.resolve_url(
-            'https://soundcloud.com/yndihalda/sets/dash-and-blast'
+            "https://soundcloud.com/yndihalda/sets/dash-and-blast"
         )
-        self.assertEquals(len(tracks), 4)
-        for i, t in enumerate(expected_tracks):
-            self.assertIsInstance(tracks[i], Track)
-            self.assertEquals(tracks[i].name, expected_tracks[i])
-            self.assertTrue(tracks[i].length > 500)
-            self.assertEquals(len(tracks[i].artists), 1)
-            self.assertEquals(list(tracks[i].artists)[0].name, 'yndi halda')
-
-    @my_vcr.use_cassette('sc-liked.yaml')
+        assert len(tracks) == 4
+        for i, _ in enumerate(expected_tracks):
+            assert isinstance(tracks[i], Track)
+            assert tracks[i].name == expected_tracks[i]
+            assert tracks[i].length > 500
+            assert len(tracks[i].artists) == 1
+            assert list(tracks[i].artists)[0].name == "yndi halda"
+
+    @my_vcr.use_cassette("sc-liked.yaml")
     def test_get_user_likes(self):
         tracks = self.api.get_likes()
-        self.assertEquals(len(tracks), 3)
-        self.assertIsInstance(tracks[0], Track)
-        self.assertEquals(tracks[1].name, 'Pelican - Deny The Absolute')
+        assert len(tracks) == 3
+        assert isinstance(tracks[0], Track)
+        assert tracks[1].name == "Pelican - Deny The Absolute"
 
-    @my_vcr.use_cassette('sc-stream.yaml')
+    @my_vcr.use_cassette("sc-stream.yaml")
     def test_get_user_stream(self):
         tracks = self.api.get_user_stream()
-        self.assertEquals(len(tracks), 10)
-        self.assertIsInstance(tracks[0], Track)
-        self.assertEquals(tracks[2].name, 'JW Ep 20- Jeremiah Watkins')
+        assert len(tracks) == 10
+        assert isinstance(tracks[0], Track)
+        assert tracks[2].name == "JW Ep 20- Jeremiah Watkins"
 
-    @my_vcr.use_cassette('sc-following.yaml')
+    @my_vcr.use_cassette("sc-following.yaml")
     def test_get_followings(self):
         users = self.api.get_followings()
-        self.assertEquals(len(users), 10)
-        self.assertEquals(users[0], (u'Young Legionnaire', '992503'))
-        self.assertEquals(users[1], (u'Tall Ships', '1710483'))
-        self.assertEquals(users[8], (u'Pelican Song', '27945548'))
-        self.assertEquals(users[9], (u'sleepmakeswaves', '1739693'))
+        assert len(users) == 10
+        assert users[0] == ("Young Legionnaire", "992503")
+        assert users[1] == ("Tall Ships", "1710483")
+        assert users[8] == ("Pelican Song", "27945548")
+        assert users[9] == ("sleepmakeswaves", "1739693")
 
-    @my_vcr.use_cassette('sc-user-tracks.yaml')
+    @my_vcr.use_cassette("sc-user-tracks.yaml")
     def test_get_user_tracks(self):
-        expected_tracks = ['The Wait',
-                           'The Cliff (Palms Remix)',
-                           'The Cliff (Justin Broadrick Remix)',
-                           'The Cliff (Vocal Version)',
-                           'Pelican - The Creeper',
-                           'Pelican - Lathe Biosas',
-                           'Pelican - Ephemeral',
-                           'Pelican - Deny the Absolute',
-                           'Pelican - Immutable Dusk',
-                           'Pelican - Strung Up From The Sky']
+        expected_tracks = [
+            "The Wait",
+            "The Cliff (Palms Remix)",
+            "The Cliff (Justin Broadrick Remix)",
+            "The Cliff (Vocal Version)",
+            "Pelican - The Creeper",
+            "Pelican - Lathe Biosas",
+            "Pelican - Ephemeral",
+            "Pelican - Deny the Absolute",
+            "Pelican - Immutable Dusk",
+            "Pelican - Strung Up From The Sky",
+        ]
 
         tracks = self.api.get_tracks(27945548)
-        for i, t in enumerate(expected_tracks):
-            self.assertIsInstance(tracks[i], Track)
-            self.assertEquals(tracks[i].name, expected_tracks[i])
-            self.assertTrue(tracks[i].length > 500)
-            self.assertEquals(len(tracks[i].artists), 1)
+        for i, _ in enumerate(expected_tracks):
+            assert isinstance(tracks[i], Track)
+            assert tracks[i].name == expected_tracks[i]
+            assert tracks[i].length > 500
+            assert len(tracks[i].artists) == 1
 
-    @my_vcr.use_cassette('sc-set.yaml')
+    @my_vcr.use_cassette("sc-set.yaml")
     def test_get_set(self):
-        tracks = self.api.get_set('10961826')
-        self.assertEquals(len(tracks), 1)
-        self.assertIsInstance(tracks[0], dict)
+        tracks = self.api.get_set("10961826")
+        assert len(tracks) == 1
+        assert isinstance(tracks[0], dict)
 
-    @my_vcr.use_cassette('sc-set-invalid.yaml')
+    @my_vcr.use_cassette("sc-set-invalid.yaml")
     def test_get_invalid_set(self):
-        tracks = self.api.get_set('blahblahrubbosh')
-        self.assertEquals(tracks, [])
+        tracks = self.api.get_set("blahblahrubbosh")
+        assert tracks == []
 
-    @my_vcr.use_cassette('sc-sets.yaml')
+    @my_vcr.use_cassette("sc-sets.yaml")
     def test_get_sets(self):
         sets = self.api.get_sets()
-        self.assertEquals(len(sets), 2)
+        assert len(sets) == 2
         name, set_id, tracks = sets[1]
-        self.assertEquals(name, 'Pelican')
-        self.assertEquals(set_id, '10961826')
-        self.assertEquals(len(tracks), 1)
+        assert name == "Pelican"
+        assert set_id == "10961826"
+        assert len(tracks) == 1
 
     def test_readeble_url(self):
-        self.assertEquals('Barsuk Records',
-                          readable_url('"@"Barsuk      Records'))
-        self.assertEquals('_Barsuk Records',
-                          readable_url('_Barsuk \'Records\''))
+        assert "Barsuk Records" == readable_url('"@"Barsuk      Records')
+        assert "_Barsuk Records" == readable_url("_Barsuk 'Records'")
 
-    @my_vcr.use_cassette('sc-resolve-track-id.yaml')
+    @my_vcr.use_cassette("sc-resolve-track-id.yaml")
     def test_resolves_stream_track(self):
-        track = self.api.get_track('13158665', True)
-        self.assertIsInstance(track, Track)
-        self.assertEquals(
-            track.uri,
-            'https://cf-media.sndcdn.com/fxguEjG4ax6B.128.mp3?Policy=eyJTdGF0Z'
-            'W1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLW1lZGlhLnNuZGNkbi5jb20vZnhndU'
-            'VqRzRheDZCLjEyOC5tcDMiLCJDb25kaXRpb24iOnsiRGF0ZUxlc3NUaGFuIjp7IkF'
-            'XUzpFcG9jaFRpbWUiOjE0Nzc2MDA1NTd9fX1dfQ__&Signature=u9bxAkZOtTTF1'
-            'VqTmLGmw3ENrqbiSTFK-sMvZL-ZsQK85DOepHh5MfPA4MNooszUy~PZqiVyBn4YnE'
-            'lhWyb~4B7kS6y0VZ6t-qF78CfTMOimemafpqfWJ8nYXczhM9pUpAwiS--lkNjGks4'
-            'Qxi-FZJDBPG99gAIU0eVW78CADcpuOKLugGpzHl6gRPN2Z4zZ9dVujZ5MlG2OWnPu'
-            'NiBcE~wUFwcOxt9N6ePTff-wMFQR2PGpEK6wc6bWuB4WFNBkE0bmEke4cOQjWHa5F'
-            'wYEidZN5rvv5lVT1r07zzifnADEipwMaZ2-QYdqzOYaM4jymFDhl7DklaU24PY5C5'
-            'mH0A__&Key-Pair-Id=APKAJAGZ7VMH2PFPW6UQ'
+        track = self.api.get_track("13158665", True)
+        assert isinstance(track, Track)
+        assert track.uri == (
+            "https://cf-media.sndcdn.com/fxguEjG4ax6B.128.mp3?Policy="
+            "eyJTdGF0ZW1lbnQiOlt7IlJlc291cmNlIjoiKjovL2NmLW1lZGlhLnNu"
+            "ZGNkbi5jb20vZnhndUVqRzRheDZCLjEyOC5tcDMiLCJDb25kaXRpb24i"
+            "OnsiRGF0ZUxlc3NUaGFuIjp7IkFXUzpFcG9jaFRpbWUiOjE0Nzc2MDA1"
+            "NTd9fX1dfQ__&Signature=u9bxAkZOtTTF1VqTmLGmw3ENrqbiSTFK-"
+            "sMvZL-ZsQK85DOepHh5MfPA4MNooszUy~PZqiVyBn4YnElhWyb~4B7kS"
+            "6y0VZ6t-qF78CfTMOimemafpqfWJ8nYXczhM9pUpAwiS--lkNjGks4Qx"
+            "i-FZJDBPG99gAIU0eVW78CADcpuOKLugGpzHl6gRPN2Z4zZ9dVujZ5Ml"
+            "G2OWnPuNiBcE~wUFwcOxt9N6ePTff-wMFQR2PGpEK6wc6bWuB4WFNBkE"
+            "0bmEke4cOQjWHa5FwYEidZN5rvv5lVT1r07zzifnADEipwMaZ2-QYdqz"
+            "OYaM4jymFDhl7DklaU24PY5C5mH0A__&Key-Pair-Id=APKAJAGZ7VMH"
+            "2PFPW6UQ"
         )
 
-    @my_vcr.use_cassette('sc-search.yaml')
+    @my_vcr.use_cassette("sc-search.yaml")
     def test_search(self):
-        tracks = self.api.search('the great descent')
-        self.assertEquals(len(tracks), 10)
-        self.assertIsInstance(tracks[0], Track)
-        self.assertEquals(tracks[0].name, 'Turn Around (Mix1)')
+        tracks = self.api.search("the great descent")
+        assert len(tracks) == 10
+        assert isinstance(tracks[0], Track)
+        assert tracks[0].name == "Turn Around (Mix1)"
diff --git a/tests/test_cache.py b/tests/test_cache.py
index 306b368..7c399b3 100644
--- a/tests/test_cache.py
+++ b/tests/test_cache.py
@@ -1,35 +1,29 @@
-from __future__ import unicode_literals
-
 import unittest
-
-from mock import Mock
+from unittest import mock
 
 from mopidy_soundcloud.soundcloud import cache
 
 
 class CacheTest(unittest.TestCase):
-
     def test_decorator(self):
-        func = Mock()
+        func = mock.Mock()
         decorated_func = cache()
         decorated_func(func)
         func()
-        self.assertEquals(func.called, True)
-        self.assertEquals(decorated_func._call_count, 1)
+        assert func.called is True
+        assert decorated_func._call_count == 1
 
     def test_set_default_cache(self):
-
         @cache()
         def returnstring():
-            return 'ok'
+            return "ok"
 
-        self.assertEquals(returnstring(), 'ok')
+        assert returnstring() == "ok"
 
     def test_set_ttl_cache(self):
-
-        func = Mock()
+        func = mock.Mock()
         decorated_func = cache(func, ttl=5)
         func()
-        self.assertEquals(func.called, True)
-        self.assertEquals(decorated_func._call_count, 1)
-        self.assertEquals(decorated_func.ttl, 5)
+        assert func.called is True
+        assert decorated_func._call_count == 1
+        assert decorated_func.ttl == 5
diff --git a/tests/test_extension.py b/tests/test_extension.py
index 225c51c..6100178 100644
--- a/tests/test_extension.py
+++ b/tests/test_extension.py
@@ -1,24 +1,21 @@
-from __future__ import unicode_literals
-
 import unittest
 
-from mopidy_soundcloud import SoundCloudExtension
+from mopidy_soundcloud import Extension
 
 
 class ExtensionTest(unittest.TestCase):
-
     def test_get_default_config(self):
-        ext = SoundCloudExtension()
+        ext = Extension()
 
         config = ext.get_default_config()
 
-        self.assertIn('[soundcloud]', config)
-        self.assertIn('enabled = True', config)
+        assert "[soundcloud]" in config
+        assert "enabled = True" in config
 
     def test_get_config_schema(self):
-        ext = SoundCloudExtension()
+        ext = Extension()
 
         schema = ext.get_config_schema()
 
-        self.assertIn('auth_token', schema)
-        self.assertIn('explore_songs', schema)
+        assert "auth_token" in schema
+        assert "explore_songs" in schema
diff --git a/tests/test_library.py b/tests/test_library.py
index e2fd2ef..a6a10bc 100644
--- a/tests/test_library.py
+++ b/tests/test_library.py
@@ -1,24 +1,24 @@
-# coding=utf-8
-from __future__ import unicode_literals
-
 import unittest
 
 import pykka
 
-from mopidy_soundcloud import SoundCloudExtension, actor
+from mopidy.models import Ref
+from mopidy_soundcloud import Extension, actor
 from mopidy_soundcloud.library import (
-    SoundCloudLibraryProvider, new_folder, simplify_search_query)
+    SoundCloudLibraryProvider,
+    new_folder,
+    simplify_search_query,
+)
 from mopidy_soundcloud.soundcloud import safe_url
 
 
 class ApiTest(unittest.TestCase):
     def setUp(self):
-        config = SoundCloudExtension().get_config_schema()
-        config['auth_token'] = '1-35204-61921957-55796ebef403996'
+        config = Extension().get_config_schema()
+        config["auth_token"] = "1-35204-61921957-55796ebef403996"
         # using this user http://maildrop.cc/inbox/mopidytestuser
         self.backend = actor.SoundCloudBackend.start(
-            config={'soundcloud': config, 'proxy': {}},
-            audio=None
+            config={"soundcloud": config, "proxy": {}}, audio=None
         ).proxy()
         self.library = SoundCloudLibraryProvider(backend=self.backend)
 
@@ -26,72 +26,71 @@ class ApiTest(unittest.TestCase):
         pykka.ActorRegistry.stop_all()
 
     def test_add_folder(self):
-        try:
-            from mopidy.models import Ref
-        except ImportError as e:
-            self.skipTest(e.message)
-        self.assertEquals(
-            new_folder('Test', ['test']),
-            Ref(name='Test', type='directory',
-                uri='soundcloud:directory:test')
+        assert new_folder("Test", ["test"]) == Ref(
+            name="Test", type="directory", uri="soundcloud:directory:test"
         )
 
     def test_mpc_search(self):
-        self.assertEquals(
-            simplify_search_query({u'any': [u'explosions in the sky']}),
-            'explosions in the sky'
+        assert (
+            simplify_search_query({"any": ["explosions in the sky"]})
+            == "explosions in the sky"
         )
 
     def test_moped_search(self):
-        self.assertEquals(
+        assert (
             simplify_search_query(
                 {
-                    u'track_name': [u'explosions in the sky'],
-                    u'any': [u'explosions in the sky']
+                    "track_name": ["explosions in the sky"],
+                    "any": ["explosions in the sky"],
                 }
-            ),
-            'explosions in the sky explosions in the sky'
+            )
+            == "explosions in the sky explosions in the sky"
         )
 
     def test_simple_search(self):
-        self.assertEquals(
-            simplify_search_query('explosions in the sky'),
-            'explosions in the sky'
+        assert (
+            simplify_search_query("explosions in the sky")
+            == "explosions in the sky"
         )
 
     def test_aria_search(self):
-        self.assertEquals(
-            simplify_search_query(['explosions', 'in the sky']),
-            'explosions in the sky'
+        assert (
+            simplify_search_query(["explosions", "in the sky"])
+            == "explosions in the sky"
         )
 
     def test_only_resolves_soundcloud_uris(self):
-        self.assertIsNone(self.library.search(
-            {'uri': 'http://www.youtube.com/watch?v=wD6H6Yhluo8'}))
+        assert (
+            self.library.search(
+                {"uri": "http://www.youtube.com/watch?v=wD6H6Yhluo8"}
+            )
+            is None
+        )
 
     def test_returns_url_safe_string(self):
-        self.assertEquals(
-            safe_url('Alternative/Indie/rock/pop '),
-            'Alternative%2FIndie%2Frock%2Fpop+')
-        self.assertEquals(
-            safe_url('D∃∃P Hau⑀ iNDiE DᴬNCE | №➊ ²⁰¹⁴'),
-            'DP+Hau+iNDiE+DANCE+%7C+No+2014')
+        assert (
+            safe_url("Alternative/Indie/rock/pop ")
+            == "Alternative%2FIndie%2Frock%2Fpop+"
+        )
+        assert (
+            safe_url("D∃∃P Hau⑀ iNDiE DᴬNCE | №➊ ²⁰¹⁴")
+            == "DP+Hau+iNDiE+DANCE+%7C+No+2014"
+        )
 
     def test_default_folders(self):
-        try:
-            from mopidy.models import Ref
-        except ImportError as e:
-            self.skipTest(e.message)
-        self.assertEquals(
-            self.library.browse('soundcloud:directory'),
-            [
-                Ref(name='Following', type='directory',
-                    uri='soundcloud:directory:following'),
-                Ref(name='Liked', type='directory',
-                    uri='soundcloud:directory:liked'),
-                Ref(name='Sets', type='directory',
-                    uri='soundcloud:directory:sets'),
-                Ref(name='Stream', type='directory',
-                    uri='soundcloud:directory:stream')
-            ]
-        )
+        assert self.library.browse("soundcloud:directory") == [
+            Ref(
+                name="Following",
+                type="directory",
+                uri="soundcloud:directory:following",
+            ),
+            Ref(
+                name="Liked", type="directory", uri="soundcloud:directory:liked"
+            ),
+            Ref(name="Sets", type="directory", uri="soundcloud:directory:sets"),
+            Ref(
+                name="Stream",
+                type="directory",
+                uri="soundcloud:directory:stream",
+            ),
+        ]