diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index c5450c21..f6b6eac9 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -11,9 +11,9 @@ jobs: SPOTIPY_CLIENT_SECRET: ${{ secrets.SPOTIPY_CLIENT_SECRET }} PYTHON_VERSION: "3.10" steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python ${{ env.PYTHON_VERSION }} - uses: actions/setup-python@v1 + uses: actions/setup-python@v5 with: python-version: ${{ env.PYTHON_VERSION }} - name: Install dependencies diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2c1fe1ff..94ecb5ef 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -13,9 +13,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3.x" - name: Install pypa/build @@ -32,26 +32,8 @@ jobs: --wheel --outdir dist/ . - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: "2.x" - - name: Install pypa/build - run: >- - python -m - pip install - build - --user - - name: Build a binary wheel and a source tarball - run: >- - python -m - build - --sdist - --wheel - --outdir dist/ - . - name: Publish distribution 📦 to PyPI if: startsWith(github.ref, 'refs/tags') uses: pypa/gh-action-pypi-publish@release/v1 with: - password: ${{ secrets.PYPI_API_TOKEN }} \ No newline at end of file + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index d7871ef3..3c31d606 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -8,8 +8,8 @@ jobs: changelog: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 - - uses: dangoslen/changelog-enforcer@v1.1.1 + - uses: actions/checkout@v4 + - uses: dangoslen/changelog-enforcer@v3.6.1 with: changeLogPath: 'CHANGELOG.md' - skipLabel: 'skip-changelog' \ No newline at end of file + skipLabel: 'skip-changelog' diff --git a/.github/workflows/pythonapp.yml b/.github/workflows/pythonapp.yml index 7c83ab32..5f2ffc8f 100644 --- a/.github/workflows/pythonapp.yml +++ b/.github/workflows/pythonapp.yml @@ -8,11 +8,11 @@ jobs: runs-on: ubuntu-20.04 strategy: matrix: - python-version: ["3.7", "3.8", "3.9", "3.10"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v1 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 00000000..be0738ca --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,16 @@ +# Read the Docs configuration file for Sphinx projects +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +version: 2 + +build: + os: ubuntu-22.04 + tools: + python: "3.12" + +sphinx: + configuration: docs/conf.py + +python: + install: + - requirements: docs/requirements.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ab637da..3d498943 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,18 +6,37 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased +Add your changes below. ### Added +- Added unit tests for queue functions + +### Fixed +- + +### Removed +- `mock` no longer listed as a test dependency. Only built-in `unittest.mock` is actually used. + +## [2.24.0] - 2024-05-30 -- Added support for audiobook endpoints: get_audiobook, get_audiobooks, and get_audiobook_chapters. +### Added +- Added `MemcacheCacheHandler`, a cache handler that stores the token info using pymemcache. +- Added support for audiobook endpoints: `get_audiobook`, `get_audiobooks`, and `get_audiobook_chapters`. - Added integration tests for audiobook endpoints. -- Removed `python 2.7` from GitHub Actions CI workflow. Python v2.7 reached end of life support and is no longer supported by Ubuntu 20.04. -- Removed `python 3.6` from GitHub Actions CI workflow. Ubuntu 20.04 is not available in GitHub Actions for `python 3.6`. -- Added unit tests for queue functions +- Added `update` field to `current_user_follow_playlist`. ### Changed -- Changes the YouTube video link for authentication tutorial (the old video was in low definition, the new one is in high definition) -- Updated links to Spotify in documentation +- Fixed error obfuscation when Spotify class is being inherited and an error is raised in the Child's `__init__` +- Replaced `artist_albums(album_type=...)` with `artist_albums(include_groups=...)` due to an API change. +- Updated `_regex_spotify_url` to ignore `/intl-` in Spotify links +- Improved README, docs and examples + +### Fixed +- Readthedocs build +- Split `test_current_user_save_and_usave_tracks` unit test + +### Removed +- Drop support for EOL Python 3.7 ## [2.23.0] - 2023-04-07 @@ -61,7 +80,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Incorrect `category_id` input for test_category - Assertion value for `test_categories_limit_low` and `test_categories_limit_high` -- Pin Github Actions Runner to Ubuntu 20 for Py27 +- Pin GitHub Actions Runner to Ubuntu 20 for Py27 - Fixed potential error where `found` variable in `test_artist_related_artists` is undefined if for loop never evaluates to true - Fixed false positive test `test_new_releases` which looks up the wrong property of the JSON response object and always evaluates to true @@ -86,13 +105,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added `RedisCacheHandler`, a cache handler that stores the token info in Redis. -- Changed URI handling in `client.Spotify._get_id()` to remove qureies if provided by error. +- Changed URI handling in `client.Spotify._get_id()` to remove queries if provided by error. - Added a new parameter to `RedisCacheHandler` to allow custom keys (instead of the default `token_info` key) - Simplify check for existing token in `RedisCacheHandler` ### Changed -- Removed Python 3.5 and added Python 3.9 in Github Action +- Removed Python 3.5 and added Python 3.9 in GitHub Action ## [2.19.0] - 2021-08-12 @@ -105,7 +124,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fixed a bug in `CacheFileHandler.__init__`: The documentation says that the username will be retrieved from the environment, but it wasn't. -- Fixed a bug in the initializers for the auth managers that produced a spurious warning message if you provide a cache handler and you set a value for the "SPOTIPY_CLIENT_USERNAME" environment variable. +- Fixed a bug in the initializers for the auth managers that produced a spurious warning message if you provide a cache handler, and you set a value for the "SPOTIPY_CLIENT_USERNAME" environment variable. - Use generated MIT license and fix license type in `pip show` ## [2.18.0] - 2021-04-13 @@ -146,7 +165,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The docs for the `auth` parameter of `Spotify.init` use the term "access token" instead of "authorization token" - Changed docs for `search` to mention that you can provide multiple types to search for - The query parameters of requests are now logged -- Deprecate specifing `cache_path` or `username` directly to `SpotifyOAuth`, `SpotifyPKCE`, and `SpotifyImplicitGrant` constructors, instead directing users to use the `CacheFileHandler` cache handler +- Deprecate specifying `cache_path` or `username` directly to `SpotifyOAuth`, `SpotifyPKCE`, and `SpotifyImplicitGrant` constructors, instead directing users to use the `CacheFileHandler` cache handler - Removed requirement for examples/app.py to specify port multiple times (only SPOTIPY_REDIRECT_URI needs to contain the port) ### Added @@ -247,7 +266,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 authorization/authentication web api errors details. - Added `SpotifyStateError` subclass of `SpotifyOauthError` - Allow extending `SpotifyClientCredentials` and `SpotifyOAuth` -- Added the market paramter to `album_tracks` +- Added the market parameter to `album_tracks` ### Deprecated @@ -298,7 +317,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - retries - status_retries - backoff_factor -- Spin up a local webserver to auto-fill authentication URL +- Spin up a local webserver to autofill authentication URL - Use session in SpotifyAuthBase - Logging used instead of print statements @@ -401,7 +420,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Support for `current_user_saved_albums_contains` - Support for `user_unfollow_artists` - Support for `user_unfollow_users` -- Lint with flake8 using Github action +- Lint with flake8 using GitHub action ### Changed @@ -453,7 +472,7 @@ Fixed bug in auto retry logic ## [2.3.3] - 2015-04-01 -Aadded client credential flow +Added client credential flow ## [2.3.2] - 2015-03-31 @@ -497,7 +516,7 @@ Support for "Your Music" tracks (add, delete, get), with examples ## [1.45.0] - 2014-07-07 -Support for related artists endpoint. Don't use cache auth codes when scope changes +Support for related artists' endpoint. Don't use cache auth codes when scope changes ## [1.44.0] - 2014-07-03 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3dd2c276..aa99374d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -55,9 +55,9 @@ Don't forget to add a short description of your change in the [CHANGELOG](CHANGE - Add to changelog: ## Unreleased + Add your changes below. ### Added - - Replace with changes ### Fixed diff --git a/README.md b/README.md index aff9a396..3af3c4cc 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,20 @@ # Spotipy -##### A light weight Python library for the Spotify Web API +##### Spotipy is a lightweight Python library for the [Spotify Web API](https://developer.spotify.com/documentation/web-api). With Spotipy you get full access to all of the music data provided by the Spotify platform. -![Tests](https://github.com/plamere/spotipy/workflows/Tests/badge.svg?branch=master) [![Documentation Status](https://readthedocs.org/projects/spotipy/badge/?version=latest)](https://spotipy.readthedocs.io/en/latest/?badge=latest) +![Tests](https://github.com/plamere/spotipy/workflows/Tests/badge.svg?branch=master) [![Documentation Status](https://readthedocs.org/projects/spotipy/badge/?version=master)](https://spotipy.readthedocs.io/en/latest/?badge=master) -## Documentation +## Table of Contents -Spotipy's full documentation is online at [Spotipy Documentation](http://spotipy.readthedocs.org/). +- [Features](#features) +- [Installation](#installation) +- [Quick Start](#quick-start) +- [Reporting Issues](#reporting-issues) +- [Contributing](#contributing) + +## Features + +Spotipy supports all of the features of the Spotify Web API including access to all end points, and support for user authorization. For details on the capabilities you are encouraged to review the [Spotify Web API](https://developer.spotify.com/web-api/) documentation. ## Installation @@ -30,10 +38,9 @@ pip install spotipy --upgrade A full set of examples can be found in the [online documentation](http://spotipy.readthedocs.org/) and in the [Spotipy examples directory](https://github.com/plamere/spotipy/tree/master/examples). -To get started, install spotipy and create an app on https://developers.spotify.com/. -Add your new ID and SECRET to your environment: +To get started, [install spotipy](#installation), create a new account or log in on https://developers.spotify.com/. Go to the [dashboard](https://developer.spotify.com/dashboard), create an app and add your new ID and SECRET (ID and SECRET can be found on an app setting) to your environment ([step-by-step video](https://www.youtube.com/watch?v=kaBVN8uP358)): -### Without user authentication +### Example without user authentication ```python import spotipy @@ -46,8 +53,20 @@ results = sp.search(q='weezer', limit=20) for idx, track in enumerate(results['tracks']['items']): print(idx, track['name']) ``` +Expected result: +``` +0 Island In The Sun +1 Say It Ain't So +2 Buddy Holly +. +. +. +18 Troublemaker +19 Feels Like Summer +``` + -### With user authentication +### Example with user authentication A redirect URI must be added to your application at [My Dashboard](https://developer.spotify.com/dashboard/applications) to access user authenticated features. @@ -65,6 +84,12 @@ for idx, item in enumerate(results['items']): track = item['track'] print(idx, track['artists'][0]['name'], " – ", track['name']) ``` +Expected result will be the list of music that you liked. For example if you liked Red and Sunflower, the result will be: +``` +0 Post Malone – Sunflower - Spider-Man: Into the Spider-Verse +1 Taylor Swift – Red +``` + ## Reporting Issues @@ -77,3 +102,9 @@ Don’t forget to add the *Spotipy* tag, and any other relevant tags as well, be If you have suggestions, bugs or other issues specific to this library, file them [here](https://github.com/plamere/spotipy/issues). Or just send a pull request. + +## Contributing + +If you are a developer with Python experience, and you would like to contribute to Spotipy, please be sure to follow the guidelines listed on documentation page + +> #### [Visit the guideline](https://spotipy.readthedocs.io/en/#contribute) diff --git a/TUTORIAL.md b/TUTORIAL.md index 9bbe6ea8..54d49b29 100644 --- a/TUTORIAL.md +++ b/TUTORIAL.md @@ -7,15 +7,22 @@ In order to complete this tutorial successfully, there are a few things that you **1. pip package manager** You can check to see if you have pip installed by opening up Terminal and typing the following command: pip --version -If you see a version number, pip is installed and you're ready to proceed. If not, instructions for downloading the latest version of pip can be found here: https://pip.pypa.io/en/stable/cli/pip_download/ +If you see a version number, pip is installed, and you're ready to proceed. If not, instructions for downloading the latest version of pip can be found here: https://pip.pypa.io/en/stable/cli/pip_download/ **2. python3** -Spotipy is written in Python, so you'll need to have the lastest version of Python installed in order to use Spotipy. Check if you already have Python installed with the Terminal command: python --version +Spotipy is written in Python, so you'll need to have the latest version of Python installed in order to use Spotipy. Check if you already have Python installed with the Terminal command: python --version If you see a version number, Python is already installed. If not, you can download it here: https://www.python.org/downloads/ -**3. experience with basic Linux commands** +**3. spotipy** + +You'll need to install the packages necessary for this project. Run the following command: +``` +pip install spotipy +``` + +**4. experience with basic Linux commands** This tutorial will be easiest if you have some knowledge of how to use Linux commands to create and navigate folders and files on your computer. If you're not sure how to create, edit and delete files and directories from Terminal, learn about basic Linux commands [here](https://ubuntu.com/tutorials/command-line-for-beginners#1-overview) before continuing. @@ -26,19 +33,21 @@ Spotipy relies on the Spotify API. In order to use the Spotify API, you'll need A. Visit the [Spotify developer portal](https://developer.spotify.com/dashboard/). If you already have a Spotify account, click "Log in" and enter your username and password. Otherwise, click "Sign up" and follow the steps to create an account. After you've signed in or signed up, you should be redirected to your developer dashboard. -B. Click the "Create an App" button. Enter any name and description you'd like for your new app. Accept the terms of service and click "Create." +B. Click the "Create an App" button. Enter any name and description you'd like for your new app. Add "http://localhost:1234" (or any other port number of your choosing) as your "Redirect URI". Accept the terms of service and click "Create." -C. In your new app's Overview screen, click the "Edit Settings" button and scroll down to "Redirect URIs." Add "http://localhost:1234" (or any other port number of your choosing). Hit the "Save" button at the bottom of the Settings panel to return to you App Overview screen. +C. In your new app's Overview screen, click the "Settings" button and then under the "Basic Information" tab click "View client secret", then copy both your Client Secret and your Client ID somewhere on your computer. You'll need to access them later. -D. Underneath your app name and description on the lefthand side, you'll see a "Show Client Secret" link. Click that link to reveal your Client Secret, then copy both your Client Secret and your Client ID somewhere on your computer. You'll need to access them later. +D. Underneath your app name and description on the left-hand side, you'll see a "Show Client Secret" link. Click that link to reveal your Client Secret, then copy both your Client Secret and your Client ID somewhere on your computer. You'll need to access them later. ## Step 2. Installation and Setup -A. Create a folder somewhere on your computer where you'd like to store the code for your Spotipy app. You can create a folder in terminal with this command: mkdir folder_name +A. Create a folder somewhere on your computer where you'd like to store the code for your Spotipy app. You can create a folder in terminal with this command: ```mkdir folder_name``` + +B. Install the Spotipy library. You can do this by using this command in the terminal: ```pip install spotipy``` -B. In that folder, create a Python file named main.py. You can create the file directly from Terminal using a built in text editor like Vim, which comes preinstalled on Linux operating systems. To create the file with Vim, ensure that you are in your new directory, then run: vim main.py +C. In that folder, create a Python file named main.py. You can create the file directly from Terminal using a built in text editor like Vim, which comes preinstalled on Linux operating systems. To create the file with Vim, ensure that you are in your new directory, then run: vim main.py -C. Paste the following code into your main.py file: +D. Paste the following code into your main.py file: ``` import spotipy from spotipy.oauth2 import SpotifyOAuth @@ -48,17 +57,17 @@ sp = spotipy.Spotify(auth_manager=SpotifyOAuth(client_id="YOUR_APP_CLIENT_ID", redirect_uri="YOUR_APP_REDIRECT_URI", scope="user-library-read")) ``` -D. Replace YOUR_APP_CLIENT_ID and YOUR_APP_CLIENT_SECRET with the values you copied and saved in step 1D. Replace YOUR_APP_REDIRECT_URI with the URI you set in step 1C. +E. Replace YOUR_APP_CLIENT_ID and YOUR_APP_CLIENT_SECRET with the values you copied and saved in step 1D. Replace YOUR_APP_REDIRECT_URI with the URI you set in step 1C. ## Step 3. Start Using Spotipy After completing steps 1 and 2, your app is fully configured and ready to fetch data from the Spotify API. All that's left is to tell the API what data we're looking for, and we do that by adding some additional code to main.py. The code that follows is just an example - once you get it working, you should feel free to modify it in order to get different results. -For now, let's assume that we want to print the names of all of the albums on Spotify by Taylor Swift: +For now, let's assume that we want to print the names of all the albums on Spotify by Taylor Swift: A. First, we need to find Taylor Swift's Spotify URI (Uniform Resource Indicator). Every entity (artist, album, song, etc.) has a URI that can identify it. To find Taylor's URI, navigate to [her page on Spotify](https://open.spotify.com/artist/06HL4z0CvFAxyc27GXpf02) and look at the URI in your browser. Everything there that follows the last backslash in the URL path is Taylor's URI, in this case: 06HL4z0CvFAxyc27GXpf02 -B. Add the URI as a variable in main.py. Notice the prefix added the the URI: +B. Add the URI as a variable in main.py. Notice the prefix added the URI: ``` taylor_uri = 'spotify:artist:06HL4z0CvFAxyc27GXpf02' ``` @@ -78,4 +87,22 @@ D. Close main.py and return to the directory that contains main.py. You can then E. You may see a window open in your browser asking you to authorize the application. Do so - you will only have to do this once. -F. Return to your terminal - you should see all of Taylor's albums printed out there. \ No newline at end of file +F. Return to your terminal - you should see all of Taylor's albums printed out there. + +## Troubleshooting Tips +A. Command not found running the application "zsh: command not found: python" + +Check which Python version that you have by running the command: +```python --version ``` or ```python3 --version```. + +In most cases, the recent Python version is Python 3. You may need to update Python. Once you have updated Python to the most recent version, run the command: +``` python3 main.py``` + +B. Encountering package error: + +If you are seeing an error "ModuleNotFoundError: No module named 'spotipy'", this means you have not installed the package. This may occur if you followed the installation and setup (up to Step 3, Part D) and attempted to run the app with the missing package. +Run the command: +``` +pip install spotipy +``` +After the package is installed, run the app again. diff --git a/docs/conf.py b/docs/conf.py index 3da5998b..df657248 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # spotipy documentation build configuration file, created by # sphinx-quickstart on Thu Aug 21 11:04:39 2014. @@ -11,7 +10,6 @@ # All configuration values have a default; values that are commented out # serve to show the default. -import spotipy import sys import os @@ -20,6 +18,9 @@ # documentation root, use os.path.abspath to make it absolute, like shown here. #sys.path.insert(0, os.path.abspath('.')) sys.path.insert(0, os.path.abspath('.')) +sys.path.insert(0, os.path.abspath("..")) + +import spotipy # -- General configuration ----------------------------------------------------- @@ -28,7 +29,10 @@ # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc'] +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx_rtd_theme' +] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -94,7 +98,7 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'default' +html_theme = 'sphinx_rtd_theme' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the diff --git a/docs/index.rst b/docs/index.rst index a6fa61b8..3534bfac 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -8,63 +8,6 @@ Welcome to Spotipy! `_. With *Spotipy* you get full access to all of the music data provided by the Spotify platform. -Assuming you set the ``SPOTIPY_CLIENT_ID`` and ``SPOTIPY_CLIENT_SECRET`` -environment variables (here is a `video `_ explaining how to do so). For a longer tutorial with examples included, refer to this `video playlist `_. Below is a quick example of using *Spotipy* to list the -names of all the albums released by the artist 'Birdy':: - - import spotipy - from spotipy.oauth2 import SpotifyClientCredentials - - birdy_uri = 'spotify:artist:2WX2uTcsvV5OnS0inACecP' - spotify = spotipy.Spotify(client_credentials_manager=SpotifyClientCredentials()) - - results = spotify.artist_albums(birdy_uri, album_type='album') - albums = results['items'] - while results['next']: - results = spotify.next(results) - albums.extend(results['items']) - - for album in albums: - print(album['name']) - -Here's another example showing how to get 30 second samples and cover art -for the top 10 tracks for Led Zeppelin:: - - import spotipy - from spotipy.oauth2 import SpotifyClientCredentials - - lz_uri = 'spotify:artist:36QJpDe2go2KgaRleHCDTp' - - spotify = spotipy.Spotify(client_credentials_manager=SpotifyClientCredentials()) - results = spotify.artist_top_tracks(lz_uri) - - for track in results['tracks'][:10]: - print('track : ' + track['name']) - print('audio : ' + track['preview_url']) - print('cover art: ' + track['album']['images'][0]['url']) - print() - -Finally, here's an example that will get the URL for an artist image given the -artist's name:: - - import spotipy - import sys - from spotipy.oauth2 import SpotifyClientCredentials - - spotify = spotipy.Spotify(auth_manager=SpotifyClientCredentials()) - - if len(sys.argv) > 1: - name = ' '.join(sys.argv[1:]) - else: - name = 'Radiohead' - - results = spotify.search(q='artist:' + name, type='artist') - items = results['artists']['items'] - if len(items) > 0: - artist = items[0] - print(artist['name'], artist['images'][0]['url']) - - Features ======== @@ -80,7 +23,8 @@ Install or upgrade *Spotipy* with:: pip install spotipy --upgrade -Or you can get the source from github at https://github.com/plamere/spotipy +You can also obtain the source code from the `Spotify GitHub repository `_. + Getting Started =============== @@ -90,20 +34,28 @@ All methods require user authorization. You will need to register your app at to get the credentials necessary to make authorized calls (a *client id* and *client secret*). + + *Spotipy* supports two authorization flows: - - The **Authorization Code flow** This method is suitable for long-running applications + - **Authorization Code flow** This method is suitable for long-running applications which the user logs into once. It provides an access token that can be refreshed. .. note:: Requires you to add a redirect URI to your application at `My Dashboard `_. See `Redirect URI`_ for more details. - - The **Client Credentials flow** The method makes it possible + - **Client Credentials flow** This method makes it possible to authenticate your requests to the Spotify Web API and to obtain a higher rate limit than you would with the Authorization Code flow. +For guidance on setting your app credentials watch this `video tutorial `_ or follow the +`Spotipy Tutorial for Beginners `_. + +For a longer tutorial with examples included, refer to this `video playlist `_. + + Authorization Code Flow ======================= @@ -139,6 +91,7 @@ on Windows):: export SPOTIPY_CLIENT_SECRET='your-spotify-client-secret' export SPOTIPY_REDIRECT_URI='your-app-redirect-url' + Scopes ------ @@ -232,19 +185,77 @@ cache handler ``CacheHandler``. The default cache handler ``CacheFileHandler`` i An instance of that new class can then be passed as a parameter when creating ``SpotifyOAuth``, ``SpotifyPKCE`` or ``SpotifyImplicitGrant``. The following handlers are available and defined in the URL above. + - ``CacheFileHandler`` - ``MemoryCacheHandler`` - ``DjangoSessionCacheHandler`` - ``FlaskSessionCacheHandler`` - ``RedisCacheHandler`` + - ``MemcacheCacheHandler``: install with dependency using ``pip install "spotipy[pymemcache]"`` Feel free to contribute new cache handlers to the repo. + Examples ======================= + +Here is an example of using *Spotipy* to list the +names of all the albums released by the artist 'Birdy':: + + import spotipy + from spotipy.oauth2 import SpotifyClientCredentials + + birdy_uri = 'spotify:artist:2WX2uTcsvV5OnS0inACecP' + spotify = spotipy.Spotify(client_credentials_manager=SpotifyClientCredentials()) + + results = spotify.artist_albums(birdy_uri, album_type='album') + albums = results['items'] + while results['next']: + results = spotify.next(results) + albums.extend(results['items']) + + for album in albums: + print(album['name']) + +Here's another example showing how to get 30 second samples and cover art +for the top 10 tracks for Led Zeppelin:: + + import spotipy + from spotipy.oauth2 import SpotifyClientCredentials + + lz_uri = 'spotify:artist:36QJpDe2go2KgaRleHCDTp' + + spotify = spotipy.Spotify(client_credentials_manager=SpotifyClientCredentials()) + results = spotify.artist_top_tracks(lz_uri) + + for track in results['tracks'][:10]: + print('track : ' + track['name']) + print('audio : ' + track['preview_url']) + print('cover art: ' + track['album']['images'][0]['url']) + print() + +Finally, here's an example that will get the URL for an artist image given the +artist's name:: + + import spotipy + import sys + from spotipy.oauth2 import SpotifyClientCredentials + + spotify = spotipy.Spotify(auth_manager=SpotifyClientCredentials()) + + if len(sys.argv) > 1: + name = ' '.join(sys.argv[1:]) + else: + name = 'Radiohead' + + results = spotify.search(q='artist:' + name, type='artist') + items = results['artists']['items'] + if len(items) > 0: + artist = items[0] + print(artist['name'], artist['images'][0]['url']) There are many more examples of how to use *Spotipy* in the `Examples -Directory `_ on Github +Directory `_ on GitHub. API Reference ============== @@ -285,7 +296,7 @@ You can ask questions about Spotipy on Stack Overflow. Don’t forget to add t http://stackoverflow.com/questions/ask If you think you've found a bug, let us know at -`Spotify Issues `_ +`Spotipy Issues `_ Contribute @@ -325,7 +336,7 @@ Export the needed Environment variables::: export SPOTIPY_REDIRECT_URI=http://localhost:8080 # Make url is set in app you created to get your ID and SECRET Create virtual environment, install dependencies, run tests::: - $ virtualenv --python=python3.7 env + $ virtualenv --python=python3.12 env (env) $ pip install --user -e . (env) $ python -m unittest discover -v tests diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 00000000..339adc95 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,3 @@ +Sphinx~=7.3.7 +sphinx-rtd-theme~=2.0.0 +redis>=3.5.3 diff --git a/examples/app.py b/examples/app.py index 635a132e..821f3fb6 100644 --- a/examples/app.py +++ b/examples/app.py @@ -11,7 +11,7 @@ OPTIONAL // in development environment for debug output export FLASK_ENV=development - // so that you can invoke the app outside of the file's directory include + // so that you can invoke the app outside the file's directory include export FLASK_APP=/path/to/spotipy/examples/app.py // on Windows, use `SET` instead of `export` diff --git a/examples/audio_analysis_for_track.py b/examples/audio_analysis_for_track.py index 1f728a5a..1bef5e9f 100644 --- a/examples/audio_analysis_for_track.py +++ b/examples/audio_analysis_for_track.py @@ -1,6 +1,5 @@ # shows audio analysis for the given track -from __future__ import print_function # (at top of module) from spotipy.oauth2 import SpotifyClientCredentials import json import spotipy @@ -20,4 +19,4 @@ analysis = sp.audio_analysis(tid) delta = time.time() - start print(json.dumps(analysis, indent=4)) -print("analysis retrieved in %.2f seconds" % (delta,)) +print(f"analysis retrieved in {delta:.2f} seconds") diff --git a/examples/audio_features.py b/examples/audio_features.py index 30caddb6..4657a972 100644 --- a/examples/audio_features.py +++ b/examples/audio_features.py @@ -1,7 +1,5 @@ - # shows acoustic features for tracks for the given artist -from __future__ import print_function # (at top of module) from spotipy.oauth2 import SpotifyClientCredentials import json import spotipy @@ -33,4 +31,4 @@ analysis = sp._get(feature['analysis_url']) print(json.dumps(analysis, indent=4)) print() -print("features retrieved in %.2f seconds" % (delta,)) +print(f"features retrieved in {delta:.2f} seconds") diff --git a/examples/audio_features_for_track.py b/examples/audio_features_for_track.py index 9e156d3f..e345ca6d 100644 --- a/examples/audio_features_for_track.py +++ b/examples/audio_features_for_track.py @@ -1,8 +1,5 @@ - - # shows acoustic features for tracks for the given artist -from __future__ import print_function # (at top of module) from spotipy.oauth2 import SpotifyClientCredentials import json import spotipy @@ -22,4 +19,4 @@ features = sp.audio_features(tids) delta = time.time() - start print(json.dumps(features, indent=4)) - print("features retrieved in %.2f seconds" % (delta,)) + print(f"features retrieved in {delta:.2f} seconds") diff --git a/examples/contains_a_saved_track.py b/examples/contains_a_saved_track.py index 41da4fd4..fb6175dc 100644 --- a/examples/contains_a_saved_track.py +++ b/examples/contains_a_saved_track.py @@ -11,7 +11,7 @@ if len(sys.argv) > 1: tid = sys.argv[1] else: - print("Usage: %s track-id ..." % (sys.argv[0],)) + print(f"Usage: {sys.argv[0]} track-id ...") sys.exit() sp = spotipy.Spotify(auth_manager=SpotifyOAuth(scope=scope)) diff --git a/examples/create_playlist.py b/examples/create_playlist.py index b9f38f9f..702c25c6 100644 --- a/examples/create_playlist.py +++ b/examples/create_playlist.py @@ -24,7 +24,7 @@ def main(): scope = "playlist-modify-public" sp = spotipy.Spotify(auth_manager=SpotifyOAuth(scope=scope)) user_id = sp.me()['id'] - sp.user_playlist_create(user_id, args.playlist) + sp.user_playlist_create(user_id, args.playlist, description=args.description) if __name__ == '__main__': diff --git a/examples/delete_a_saved_track.py b/examples/delete_a_saved_track.py index 39525496..2f461531 100644 --- a/examples/delete_a_saved_track.py +++ b/examples/delete_a_saved_track.py @@ -11,7 +11,7 @@ if len(sys.argv) > 1: tid = sys.argv[1] else: - print("Usage: %s track-id ..." % (sys.argv[0],)) + print(f"Usage: {sys.argv[0]} track-id ...") sys.exit() sp = spotipy.Spotify(auth_manager=SpotifyOAuth(scope=scope)) diff --git a/examples/remove_specific_tracks_from_playlist.py b/examples/remove_specific_tracks_from_playlist.py index 963eaefc..340f3795 100644 --- a/examples/remove_specific_tracks_from_playlist.py +++ b/examples/remove_specific_tracks_from_playlist.py @@ -15,8 +15,7 @@ track_ids.append({"uri": tid, "positions": [int(pos)]}) else: print( - "Usage: %s playlist_id track_id,pos track_id,pos ..." % - (sys.argv[0],)) + f"Usage: {sys.argv[0]} playlist_id track_id,pos track_id,pos ...") sys.exit() scope = 'playlist-modify-public' diff --git a/examples/remove_tracks_from_playlist.py b/examples/remove_tracks_from_playlist.py index 8a51c569..4e011eb3 100644 --- a/examples/remove_tracks_from_playlist.py +++ b/examples/remove_tracks_from_playlist.py @@ -10,7 +10,7 @@ playlist_id = sys.argv[2] track_ids = sys.argv[3:] else: - print("Usage: %s playlist_id track_id ..." % (sys.argv[0])) + print(f"Usage: {sys.argv[0]} playlist_id track_id ...") sys.exit() scope = 'playlist-modify-public' diff --git a/examples/replace_tracks_in_playlist.py b/examples/replace_tracks_in_playlist.py index 6d1c46fd..6c76b056 100644 --- a/examples/replace_tracks_in_playlist.py +++ b/examples/replace_tracks_in_playlist.py @@ -10,7 +10,7 @@ playlist_id = sys.argv[1] track_ids = sys.argv[2:] else: - print("Usage: %s playlist_id track_id ..." % (sys.argv[0],)) + print(f"Usage: {sys.argv[0]} playlist_id track_id ...") sys.exit() scope = 'playlist-modify-public' diff --git a/examples/show_album.py b/examples/show_album.py index 248e3055..8f5e617a 100644 --- a/examples/show_album.py +++ b/examples/show_album.py @@ -1,4 +1,3 @@ - # shows album info for a URN or URL from spotipy.oauth2 import SpotifyClientCredentials diff --git a/examples/show_related.py b/examples/show_related.py index 6fed03fd..87914045 100644 --- a/examples/show_related.py +++ b/examples/show_related.py @@ -1,4 +1,3 @@ - # shows related artists for the given seed artist from spotipy.oauth2 import SpotifyClientCredentials diff --git a/examples/simple_artist_albums.py b/examples/simple_artist_albums.py index c4cc5621..3f8323db 100644 --- a/examples/simple_artist_albums.py +++ b/examples/simple_artist_albums.py @@ -13,4 +13,4 @@ albums.extend(results['items']) for album in albums: - print((album['name'])) + print(album['name']) diff --git a/examples/simple_artist_top_tracks.py b/examples/simple_artist_top_tracks.py index 1a207b2a..caf5fed4 100644 --- a/examples/simple_artist_top_tracks.py +++ b/examples/simple_artist_top_tracks.py @@ -1,4 +1,3 @@ - from spotipy.oauth2 import SpotifyClientCredentials import spotipy diff --git a/examples/title_chain.py b/examples/title_chain.py index f3bc321c..6cf3b8e7 100644 --- a/examples/title_chain.py +++ b/examples/title_chain.py @@ -13,7 +13,7 @@ sp = spotipy.Spotify(client_credentials_manager=client_credentials_manager) -skiplist = set(['dm', 'remix']) +skiplist = {'dm', 'remix'} max_offset = 500 seen = set() diff --git a/examples/user_playlists_contents.py b/examples/user_playlists_contents.py index 13b576f5..9379a0b8 100644 --- a/examples/user_playlists_contents.py +++ b/examples/user_playlists_contents.py @@ -25,8 +25,7 @@ def show_tracks(results): print(playlist['name']) print(' total tracks', playlist['tracks']['total']) - results = sp.playlist(playlist['id'], fields="tracks,next") - tracks = results['tracks'] + tracks = sp.playlist_items(playlist['id'], fields="items,next", additional_types=('tracks', )) show_tracks(tracks) while tracks['next']: diff --git a/setup.py b/setup.py index dd1ab177..be5a982c 100644 --- a/setup.py +++ b/setup.py @@ -1,24 +1,19 @@ from setuptools import setup -with open("README.md", "r") as f: +with open("README.md") as f: long_description = f.read() -test_reqs = [ - 'mock==2.0.0' -] - -doc_reqs = [ - 'Sphinx>=1.5.2' +memcache_cache_reqs = [ + 'pymemcache>=3.5.2' ] extra_reqs = { - 'doc': doc_reqs, - 'test': test_reqs + 'memcache': memcache_cache_reqs } setup( name='spotipy', - version='2.23.0', + version='2.24.0', description='A light weight Python library for the Spotify Web API', long_description=long_description, long_description_content_type="text/markdown", @@ -28,14 +23,12 @@ project_urls={ 'Source': 'https://github.com/plamere/spotipy', }, + python_requires='>3.8', install_requires=[ - "redis>=3.5.3", - "redis<4.0.0;python_version<'3.4'", + "redis>=3.5.3", # TODO: Move to extras_require in v3 "requests>=2.25.0", - "six>=1.15.0", "urllib3>=1.26.0" ], - tests_require=test_reqs, extras_require=extra_reqs, license='MIT', packages=['spotipy']) diff --git a/spotipy/cache_handler.py b/spotipy/cache_handler.py index 9a6d703b..7ae94a23 100644 --- a/spotipy/cache_handler.py +++ b/spotipy/cache_handler.py @@ -4,7 +4,8 @@ 'DjangoSessionCacheHandler', 'FlaskSessionCacheHandler', 'MemoryCacheHandler', - 'RedisCacheHandler'] + 'RedisCacheHandler', + 'MemcacheCacheHandler'] import errno import json @@ -80,7 +81,7 @@ def get_cached_token(self): f.close() token_info = json.loads(token_info_string) - except IOError as error: + except OSError as error: if error.errno == errno.ENOENT: logger.debug("cache does not exist at: %s", self.cache_path) else: @@ -93,7 +94,7 @@ def save_token_to_cache(self, token_info): f = open(self.cache_path, "w") f.write(json.dumps(token_info, cls=self.encoder_cls)) f.close() - except IOError: + except OSError: logger.warning('Couldn\'t write token to cache at: %s', self.cache_path) @@ -208,3 +209,34 @@ def save_token_to_cache(self, token_info): self.redis.set(self.key, json.dumps(token_info)) except RedisError as e: logger.warning('Error saving token to cache: ' + str(e)) + + +class MemcacheCacheHandler(CacheHandler): + """A Cache handler that stores the token info in Memcache using the pymemcache client + """ + def __init__(self, memcache, key=None) -> None: + """ + Parameters: + * memcache: memcache client object provided by pymemcache + (https://pymemcache.readthedocs.io/en/latest/getting_started.html) + * key: May be supplied, will otherwise be generated + (takes precedence over `token_info`) + """ + self.memcache = memcache + self.key = key if key else 'token_info' + + def get_cached_token(self): + from pymemcache import MemcacheError + try: + token_info = self.memcache.get(self.key) + if token_info: + return json.loads(token_info.decode()) + except MemcacheError as e: + logger.warning('Error getting token from cache' + str(e)) + + def save_token_to_cache(self, token_info): + from pymemcache import MemcacheError + try: + self.memcache.set(self.key, json.dumps(token_info)) + except MemcacheError as e: + logger.warning('Error saving token to cache' + str(e)) diff --git a/spotipy/client.py b/spotipy/client.py index a026e412..d83053ca 100644 --- a/spotipy/client.py +++ b/spotipy/client.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - """ A simple and thin Python library for the Spotify Web API """ __all__ = ["Spotify", "SpotifyException"] @@ -10,7 +8,6 @@ import warnings import requests -import six import urllib3 from spotipy.exceptions import SpotifyException @@ -20,7 +17,7 @@ logger = logging.getLogger(__name__) -class Spotify(object): +class Spotify: """ Example usage:: @@ -103,22 +100,26 @@ class Spotify(object): # # Unfortunately the IANA specification is out of date and doesn't include the new types # show and episode. Additionally, for the user URI, it does not specify which characters - # are valid for usernames, so the assumption is alphanumeric which coincidentially are also + # are valid for usernames, so the assumption is alphanumeric which coincidentally are also # the same ones base-62 uses. # In limited manual exploration this seems to hold true, as newly accounts are assigned an # identifier that looks like the base-62 of all other IDs, but some older accounts only have # numbers and even older ones seemed to have been allowed to freely pick this name. # # [1] https://www.iana.org/assignments/uri-schemes/prov/spotify - # [2] https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids + # [2] https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids _regex_spotify_uri = r'^spotify:(?:(?Ptrack|artist|album|playlist|show|episode|audiobook):(?P[0-9A-Za-z]+)|user:(?P[0-9A-Za-z]+):playlist:(?P[0-9A-Za-z]+))$' # noqa: E501 # Spotify URLs are defined at [1]. The assumption is made that they are all # pointing to open.spotify.com, so a regex is used to parse them as well, # instead of a more complex URL parsing function. + # Spotify recently added "/intl-" to their links. This change is undocumented. + # There is an assumption that the country code uses the ISO 3166-1 alpha-2 standard [2], + # but this has not been confirmed yet. Spotipy has no use for this, so it gets ignored. # - # [1] https://developer.spotify.com/documentation/web-api/#spotify-uris-and-ids - _regex_spotify_url = r'^(http[s]?:\/\/)?open.spotify.com\/(?Ptrack|artist|album|playlist|show|episode|user|audiobook)\/(?P[0-9A-Za-z]+)(\?.*)?$' # noqa: E501 + # [1] https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids + # [2] https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2 + _regex_spotify_url = r'^(http[s]?:\/\/)?open.spotify.com\/(intl-\w\w\/)?(?Ptrack|artist|album|playlist|show|episode|user|audiobook)\/(?P[0-9A-Za-z]+)(\?.*)?$' # noqa: E501 _regex_base62 = r'^[0-9A-Za-z]+$' @@ -211,8 +212,11 @@ def auth_manager(self, auth_manager): def __del__(self): """Make sure the connection (pool) gets closed""" - if isinstance(self._session, requests.Session): - self._session.close() + try: + if isinstance(self._session, requests.Session): + self._session.close() + except AttributeError: + pass def _build_session(self): self._session = requests.Session() @@ -231,14 +235,14 @@ def _build_session(self): def _auth_headers(self): if self._auth: - return {"Authorization": "Bearer {0}".format(self._auth)} + return {"Authorization": f"Bearer {self._auth}"} if not self.auth_manager: return {} try: token = self.auth_manager.get_access_token(as_dict=False) except TypeError: token = self.auth_manager.get_access_token() - return {"Authorization": "Bearer {0}".format(token)} + return {"Authorization": f"Bearer {token}"} def _internal_call(self, method, url, payload, params): args = dict(params=params) @@ -293,7 +297,7 @@ def _internal_call(self, method, url, payload, params): raise SpotifyException( response.status_code, -1, - "%s:\n %s" % (response.url, msg), + f"{response.url}:\n {msg}", reason=reason, headers=response.headers, ) @@ -307,7 +311,7 @@ def _internal_call(self, method, url, payload, params): raise SpotifyException( 429, -1, - "%s:\n %s" % (request.path_url, "Max Retries"), + f"{request.path_url}:\n Max Retries", reason=reason ) except ValueError: @@ -402,22 +406,33 @@ def artists(self, artists): return self._get("artists/?ids=" + ",".join(tlist)) def artist_albums( - self, artist_id, album_type=None, country=None, limit=20, offset=0 + self, artist_id, album_type=None, include_groups=None, country=None, limit=20, offset=0 ): """ Get Spotify catalog information about an artist's albums Parameters: - artist_id - the artist ID, URI or URL - - album_type - 'album', 'single', 'appears_on', 'compilation' + - include_groups - the types of items to return. One or more of 'album', 'single', + 'appears_on', 'compilation'. If multiple types are desired, + pass in a comma separated string; e.g., 'album,single'. - country - limit the response to one particular country. - limit - the number of albums to return - offset - the index of the first album to return """ + if album_type: + warnings.warn( + "You're using `artist_albums(..., album_type='...')` which will be removed in " + "future versions. Please adjust your code accordingly by using " + "`artist_albums(..., include_groups='...')` instead.", + DeprecationWarning, + ) + include_groups = include_groups or album_type + trid = self._get_id("artist", artist_id) return self._get( "artists/" + trid + "/albums", - album_type=album_type, + include_groups=include_groups, country=country, limit=limit, offset=offset, @@ -649,7 +664,7 @@ def playlist(self, playlist_id, fields=None, market=None, additional_types=("tra """ plid = self._get_id("playlist", playlist_id) return self._get( - "playlists/%s" % (plid), + f"playlists/{plid}", fields=fields, market=market, additional_types=",".join(additional_types), @@ -705,7 +720,7 @@ def playlist_items( """ plid = self._get_id("playlist", playlist_id) return self._get( - "playlists/%s/tracks" % (plid), + f"playlists/{plid}/tracks", limit=limit, offset=offset, fields=fields, @@ -720,7 +735,7 @@ def playlist_cover_image(self, playlist_id): - playlist_id - the playlist ID, URI or URL """ plid = self._get_id("playlist", playlist_id) - return self._get("playlists/%s/images" % (plid)) + return self._get(f"playlists/{plid}/images") def playlist_upload_cover_image(self, playlist_id, image_b64): """ Replace the image used to represent a specific playlist @@ -732,7 +747,7 @@ def playlist_upload_cover_image(self, playlist_id, image_b64): """ plid = self._get_id("playlist", playlist_id) return self._put( - "playlists/{}/images".format(plid), + f"playlists/{plid}/images", payload=image_b64, content_type="image/jpeg", ) @@ -751,7 +766,7 @@ def user_playlist(self, user, playlist_id=None, fields=None, market=None): - fields - which fields to return """ if playlist_id is None: - return self._get("users/%s/starred" % user) + return self._get(f"users/{user}/starred") return self.playlist(playlist_id, fields=fields, market=market) def user_playlist_tracks( @@ -795,7 +810,7 @@ def user_playlists(self, user, limit=50, offset=0): - offset - the index of the first item to return """ return self._get( - "users/%s/playlists" % user, limit=limit, offset=offset + f"users/{user}/playlists", limit=limit, offset=offset ) def user_playlist_create(self, user, name, public=True, collaborative=False, description=""): @@ -815,7 +830,7 @@ def user_playlist_create(self, user, name, public=True, collaborative=False, des "description": description } - return self._post("users/%s/playlists" % (user,), payload=data) + return self._post(f"users/{user}/playlists", payload=data) def user_playlist_change_details( self, @@ -990,7 +1005,7 @@ def user_playlist_remove_specific_occurrences_of_tracks( if snapshot_id: payload["snapshot_id"] = snapshot_id return self._delete( - "users/%s/playlists/%s/tracks" % (user, plid), payload=payload + f"users/{user}/playlists/{plid}/tracks", payload=payload ) def user_playlist_follow_playlist(self, playlist_owner_id, playlist_id): @@ -1047,16 +1062,16 @@ def playlist_change_details( """ data = {} - if isinstance(name, six.string_types): + if isinstance(name, str): data["name"] = name if isinstance(public, bool): data["public"] = public if isinstance(collaborative, bool): data["collaborative"] = collaborative - if isinstance(description, six.string_types): + if isinstance(description, str): data["description"] = description return self._put( - "playlists/%s" % (self._get_id("playlist", playlist_id)), payload=data + f"playlists/{self._get_id('playlist', playlist_id)}", payload=data ) def current_user_unfollow_playlist(self, playlist_id): @@ -1067,7 +1082,7 @@ def current_user_unfollow_playlist(self, playlist_id): - name - the name of the playlist """ return self._delete( - "playlists/%s/followers" % (playlist_id) + f"playlists/{playlist_id}/followers" ) def playlist_add_items( @@ -1083,7 +1098,7 @@ def playlist_add_items( plid = self._get_id("playlist", playlist_id) ftracks = [self._get_uri("track", tid) for tid in items] return self._post( - "playlists/%s/tracks" % (plid), + f"playlists/{plid}/tracks", payload=ftracks, position=position, ) @@ -1099,7 +1114,7 @@ def playlist_replace_items(self, playlist_id, items): ftracks = [self._get_uri("track", tid) for tid in items] payload = {"uris": ftracks} return self._put( - "playlists/%s/tracks" % (plid), payload=payload + f"playlists/{plid}/tracks", payload=payload ) def playlist_reorder_items( @@ -1130,7 +1145,7 @@ def playlist_reorder_items( if snapshot_id: payload["snapshot_id"] = snapshot_id return self._put( - "playlists/%s/tracks" % (plid), payload=payload + f"playlists/{plid}/tracks", payload=payload ) def playlist_remove_all_occurrences_of_items( @@ -1151,7 +1166,7 @@ def playlist_remove_all_occurrences_of_items( if snapshot_id: payload["snapshot_id"] = snapshot_id return self._delete( - "playlists/%s/tracks" % (plid), payload=payload + f"playlists/{plid}/tracks", payload=payload ) def playlist_remove_specific_occurrences_of_items( @@ -1182,10 +1197,10 @@ def playlist_remove_specific_occurrences_of_items( if snapshot_id: payload["snapshot_id"] = snapshot_id return self._delete( - "playlists/%s/tracks" % (plid), payload=payload + f"playlists/{plid}/tracks", payload=payload ) - def current_user_follow_playlist(self, playlist_id): + def current_user_follow_playlist(self, playlist_id, public=True): """ Add the current authenticated user as a follower of a playlist. @@ -1194,7 +1209,8 @@ def current_user_follow_playlist(self, playlist_id): """ return self._put( - "playlists/{}/followers".format(playlist_id) + f"playlists/{playlist_id}/followers", + payload={"public": public} ) def playlist_is_following( @@ -1736,7 +1752,7 @@ def audio_features(self, tracks=[]): tlist = [self._get_id("track", t) for t in tracks] results = self._get("audio-features/?ids=" + ",".join(tlist)) # the response has changed, look for the new style first, and if - # its not there, fallback on the old style + # it's not there, fallback on the old style if "audio_features" in results: return results["audio_features"] else: @@ -1860,7 +1876,7 @@ def seek_track(self, position_ms, device_id=None): return return self._put( self._append_device_id( - "me/player/seek?position_ms=%s" % position_ms, device_id + f"me/player/seek?position_ms={position_ms}", device_id ) ) @@ -1876,7 +1892,7 @@ def repeat(self, state, device_id=None): return self._put( self._append_device_id( - "me/player/repeat?state=%s" % state, device_id + f"me/player/repeat?state={state}", device_id ) ) @@ -1895,7 +1911,7 @@ def volume(self, volume_percent, device_id=None): return self._put( self._append_device_id( - "me/player/volume?volume_percent=%s" % volume_percent, + f"me/player/volume?volume_percent={volume_percent}", device_id, ) ) @@ -1913,7 +1929,7 @@ def shuffle(self, state, device_id=None): state = str(state).lower() self._put( self._append_device_id( - "me/player/shuffle?state=%s" % state, device_id + f"me/player/shuffle?state={state}", device_id ) ) @@ -1924,7 +1940,7 @@ def queue(self): def add_to_queue(self, uri, device_id=None): """ Adds a song to the end of a user's queue - If device A is currently playing music and you try to add to the queue + If device A is currently playing music, and you try to add to the queue and pass in the id for device B, you will get a 'Player command failed: Restriction violated' error I therefore recommend leaving device_id as None so that the active device is targeted @@ -1938,10 +1954,10 @@ def add_to_queue(self, uri, device_id=None): uri = self._get_uri("track", uri) - endpoint = "me/player/queue?uri=%s" % uri + endpoint = f"me/player/queue?uri={uri}" if device_id is not None: - endpoint += "&device_id=%s" % device_id + endpoint += f"&device_id={device_id}" return self._post(endpoint) @@ -1960,9 +1976,9 @@ def _append_device_id(self, path, device_id): """ if device_id: if "?" in path: - path += "&device_id=%s" % device_id + path += f"&device_id={device_id}" else: - path += "?device_id=%s" % device_id + path += f"?device_id={device_id}" return path def _get_id(self, type, id): diff --git a/spotipy/exceptions.py b/spotipy/exceptions.py index df503f10..28b91419 100644 --- a/spotipy/exceptions.py +++ b/spotipy/exceptions.py @@ -12,5 +12,5 @@ def __init__(self, http_status, code, msg, reason=None, headers=None): self.headers = headers def __str__(self): - return 'http status: {0}, code:{1} - {2}, reason: {3}'.format( + return 'http status: {}, code:{} - {}, reason: {}'.format( self.http_status, self.code, self.msg, self.reason) diff --git a/spotipy/oauth2.py b/spotipy/oauth2.py index 125c87c9..4c0cd906 100644 --- a/spotipy/oauth2.py +++ b/spotipy/oauth2.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - __all__ = [ "SpotifyClientCredentials", "SpotifyOAuth", @@ -17,11 +15,9 @@ import webbrowser import requests -# Workaround to support both python 2 & 3 -import six -import six.moves.urllib.parse as urllibparse -from six.moves.BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer -from six.moves.urllib_parse import parse_qsl, urlparse +import urllib.parse as urllibparse +from http.server import BaseHTTPRequestHandler, HTTPServer +from urllib.parse import parse_qsl, urlparse from spotipy.cache_handler import CacheFileHandler, CacheHandler from spotipy.util import CLIENT_CREDS_ENV_VARS, get_host_port, normalize_scope @@ -36,7 +32,7 @@ def __init__(self, message, error=None, error_description=None, *args, **kwargs) self.error = error self.error_description = error_description self.__dict__.update(kwargs) - super(SpotifyOauthError, self).__init__(message, *args, **kwargs) + super().__init__(message, *args, **kwargs) class SpotifyStateError(SpotifyOauthError): @@ -45,7 +41,7 @@ class SpotifyStateError(SpotifyOauthError): def __init__(self, local_state=None, remote_state=None, message=None, error=None, error_description=None, *args, **kwargs): if not message: - message = ("Expected " + local_state + " but recieved " + message = ("Expected " + local_state + " but received " + remote_state) super(SpotifyOauthError, self).__init__(message, error, error_description, *args, @@ -54,24 +50,21 @@ def __init__(self, local_state=None, remote_state=None, message=None, def _make_authorization_headers(client_id, client_secret): auth_header = base64.b64encode( - six.text_type(client_id + ":" + client_secret).encode("ascii") + str(client_id + ":" + client_secret).encode("ascii") ) - return {"Authorization": "Basic %s" % auth_header.decode("ascii")} + return {"Authorization": f"Basic {auth_header.decode('ascii')}"} def _ensure_value(value, env_key): env_val = CLIENT_CREDS_ENV_VARS[env_key] _val = value or os.getenv(env_val) if _val is None: - msg = "No %s. Pass it or set a %s environment variable." % ( - env_key, - env_val, - ) + msg = f"No {env_key}. Pass it or set a {env_val} environment variable." raise SpotifyOauthError(msg) return _val -class SpotifyAuthBase(object): +class SpotifyAuthBase: def __init__(self, requests_session): if isinstance(requests_session, requests.Session): self._session = requests_session @@ -144,9 +137,7 @@ def _handle_oauth_error(self, http_error): error_description = None raise SpotifyOauthError( - 'error: {0}, error_description: {1}'.format( - error, error_description - ), + f'error: {error}, error_description: {error_description}', error=error, error_description=error_description ) @@ -196,7 +187,7 @@ def __init__( """ - super(SpotifyClientCredentials, self).__init__(requests_session) + super().__init__(requests_session) self.client_id = client_id self.client_secret = client_secret @@ -319,7 +310,7 @@ def __init__( * requests_session: A Requests session * requests_timeout: Optional, tell Requests to stop waiting for a response after a given number of seconds - * open_browser: Optional, whether or not the web browser should be opened to + * open_browser: Optional, whether the web browser should be opened to authorize a user * cache_handler: An instance of the `CacheHandler` class to handle getting and saving cached authorization tokens. @@ -327,7 +318,7 @@ def __init__( (takes precedence over `cache_path` and `username`) """ - super(SpotifyOAuth, self).__init__(requests_session) + super().__init__(requests_session) self.client_id = client_id self.client_secret = client_secret @@ -402,7 +393,7 @@ def get_authorize_url(self, state=None): urlparams = urllibparse.urlencode(payload) - return "%s?%s" % (self.OAUTH_AUTHORIZE_URL, urlparams) + return f"{self.OAUTH_AUTHORIZE_URL}?{urlparams}" def parse_response_code(self, url): """ Parse the response code in the given response url @@ -421,8 +412,7 @@ def parse_auth_response_url(url): query_s = urlparse(url).query form = dict(parse_qsl(query_s)) if "error" in form: - raise SpotifyOauthError("Received error from auth server: " - "{}".format(form["error"]), + raise SpotifyOauthError(f"Received error from auth server: {form['error']}", error=form["error"]) return tuple(form.get(param) for param in ["state", "code"]) @@ -669,7 +659,7 @@ def __init__(self, * requests_timeout: Optional, tell Requests to stop waiting for a response after a given number of seconds * requests_session: A Requests session - * open_browser: Optional, whether or not the web browser should be opened to + * open_browser: Optional, whether the web browser should be opened to authorize a user * cache_handler: An instance of the `CacheHandler` class to handle getting and saving cached authorization tokens. @@ -677,7 +667,7 @@ def __init__(self, (takes precedence over `cache_path` and `username`) """ - super(SpotifyPKCE, self).__init__(requests_session) + super().__init__(requests_session) self.client_id = client_id self.redirect_uri = redirect_uri self.state = state @@ -727,15 +717,8 @@ def _get_code_verifier(self): length = random.randint(33, 96) # The seeded length generates between a 44 and 128 base64 characters encoded string - try: - import secrets - verifier = secrets.token_urlsafe(length) - except ImportError: # For python 3.5 support - import base64 - import os - rand_bytes = os.urandom(length) - verifier = base64.urlsafe_b64encode(rand_bytes).decode('utf-8').replace('=', '') - return verifier + import secrets + return secrets.token_urlsafe(length) def _get_code_challenge(self): """ Spotify PCKE code challenge - See step 1 of the reference guide below @@ -766,7 +749,7 @@ def get_authorize_url(self, state=None): if state is not None: payload["state"] = state urlparams = urllibparse.urlencode(payload) - return "%s?%s" % (self.OAUTH_AUTHORIZE_URL, urlparams) + return f"{self.OAUTH_AUTHORIZE_URL}?{urlparams}" def _open_auth_url(self, state=None): auth_url = self.get_authorize_url(state) @@ -817,7 +800,7 @@ def _get_auth_response_local_server(self, redirect_port): if server.auth_code is not None: return server.auth_code elif server.error is not None: - raise SpotifyOauthError("Received error from OAuth server: {}".format(server.error)) + raise SpotifyOauthError(f"Received error from OAuth server: {server.error}") else: raise SpotifyOauthError("Server listening on localhost has not been accessed") @@ -1012,7 +995,7 @@ class SpotifyImplicitGrant(SpotifyAuthBase): Authentication Code flow. Use the SpotifyPKCE auth manager instead of SpotifyImplicitGrant. - SpotifyPKCE contains all of the functionality of + SpotifyPKCE contains all the functionality of SpotifyImplicitGrant, plus automatic response retrieval and refreshable tokens. Only a few replacements need to be made: @@ -1160,7 +1143,7 @@ def get_authorize_url(self, state=None): urlparams = urllibparse.urlencode(payload) - return "%s?%s" % (self.OAUTH_AUTHORIZE_URL, urlparams) + return f"{self.OAUTH_AUTHORIZE_URL}?{urlparams}" def parse_response_token(self, url, state=None): """ Parse the response code in the given response url """ @@ -1180,8 +1163,7 @@ def parse_auth_response_url(url): form = dict(i.split('=') for i in (fragment_s or query_s or url).split('&')) if "error" in form: - raise SpotifyOauthError("Received error from auth server: " - "{}".format(form["error"]), + raise SpotifyOauthError(f"Received error from auth server: {form['error']}", state=form["state"]) if "expires_in" in form: form["expires_in"] = int(form["expires_in"]) @@ -1273,7 +1255,7 @@ def do_GET(self): if self.server.auth_code: status = "successful" elif self.server.error: - status = "failed ({})".format(self.server.error) + status = f"failed ({self.server.error})" else: self._write("

Invalid request

") return diff --git a/spotipy/util.py b/spotipy/util.py index b949a618..7e586734 100644 --- a/spotipy/util.py +++ b/spotipy/util.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - """ Shows a user's playlists (need to be authenticated via oauth) """ __all__ = ["CLIENT_CREDS_ENV_VARS", "prompt_for_user_token"] diff --git a/tests/integration/non_user_endpoints/test.py b/tests/integration/non_user_endpoints/test.py index ca2faace..583cc3d5 100644 --- a/tests/integration/non_user_endpoints/test.py +++ b/tests/integration/non_user_endpoints/test.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - from spotipy import ( Spotify, SpotifyClientCredentials, diff --git a/tests/integration/user_endpoints/test.py b/tests/integration/user_endpoints/test.py index d2f824d5..71234cd7 100644 --- a/tests/integration/user_endpoints/test.py +++ b/tests/integration/user_endpoints/test.py @@ -253,7 +253,7 @@ def test_current_user_saved_tracks(self): tracks = self.spotify.current_user_saved_tracks() self.assertGreaterEqual(len(tracks['items']), 0) - def test_current_user_save_and_unsave_tracks(self): + def test_current_user_save_tracks(self): tracks = self.spotify.current_user_saved_tracks() total = tracks['total'] self.spotify.current_user_saved_tracks_add(self.four_tracks) @@ -266,6 +266,19 @@ def test_current_user_save_and_unsave_tracks(self): self.four_tracks) tracks = self.spotify.current_user_saved_tracks() new_total = tracks['total'] + + def test_current_user_unsave_tracks(self): + tracks = self.spotify.current_user_saved_tracks() + total = tracks['total'] + self.spotify.current_user_saved_tracks_add(self.four_tracks) + + tracks = self.spotify.current_user_saved_tracks() + new_total = tracks['total'] + + self.spotify.current_user_saved_tracks_delete( + self.four_tracks) + tracks = self.spotify.current_user_saved_tracks() + new_total = tracks['total'] self.assertEqual(new_total, total) def test_current_user_saved_albums(self): diff --git a/tests/unit/test_oauth.py b/tests/unit/test_oauth.py index fa58a162..10e1062b 100644 --- a/tests/unit/test_oauth.py +++ b/tests/unit/test_oauth.py @@ -1,20 +1,15 @@ -# -*- coding: utf-8 -*- import io import json import unittest -import six.moves.urllib.parse as urllibparse +import unittest.mock as mock +import urllib.parse as urllibparse from spotipy import SpotifyOAuth, SpotifyImplicitGrant, SpotifyPKCE from spotipy.cache_handler import MemoryCacheHandler from spotipy.oauth2 import SpotifyClientCredentials, SpotifyOauthError from spotipy.oauth2 import SpotifyStateError -try: - import unittest.mock as mock -except ImportError: - import mock - patch = mock.patch DEFAULT = mock.DEFAULT diff --git a/tox.ini b/tox.ini index b0f5bff0..bbf780fa 100644 --- a/tox.ini +++ b/tox.ini @@ -1,10 +1,8 @@ [tox] -envlist = py27,py34 +envlist = py3{8,9,10,11,12} [testenv] deps= requests - six - py27: mock commands=python -m unittest discover -v tests [flake8] max-line-length = 99