diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 00000000..b819ae48 --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,14 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +| ------- | ------------------ | +| 2.x | :white_check_mark: | +| 1.x | :x: | + +## Reporting a Vulnerability + +Report via https://github.com/spotipy-dev/spotipy/security/advisories. + +Guidance: https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..91abb11f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "pip" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index c5450c21..a203147a 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -1,6 +1,6 @@ name: Integration tests -on: [push, pull_request_target] +on: [push] jobs: build: @@ -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 new file mode 100644 index 00000000..94ecb5ef --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,39 @@ +name: Publish to PyPI + +on: + push: + branches-ignore: + - '**' + tags: + - '*.*.*' + +jobs: + build-n-publish: + name: Build and publish Python 🐍 distributions 📩 to PyPI and TestPyPI + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.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 }} 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 81bc9643..5f2ffc8f 100644 --- a/.github/workflows/pythonapp.yml +++ b/.github/workflows/pythonapp.yml @@ -8,12 +8,11 @@ jobs: runs-on: ubuntu-20.04 strategy: matrix: - python-version: [3.5, 3.6, 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 6bc3a3e5..8f1cf05f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,44 +12,94 @@ Rebasing master onto v3 doesn't require a changelog update. ### Added -* `Scope` - An enum which contains all of the authorization scopes (see [here](https://github.com/plamere/spotipy/issues/652#issuecomment-797461311)). +- `Scope` - An enum which contains all of the authorization scopes (see [here](https://github.com/plamere/spotipy/issues/652#issuecomment-797461311)). ### Changed -* Made `CacheHandler` an abstract base class -* Modified the return structure of the `audio_features` function (wrapping the [Get Audio Features for Several Tracks](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-several-audio-features) API) to conform to the return structure of the similar methods listed below. The functions wrapping these APIs do not unwrap the single key JSON response, and this is currently the only function that does this. - * [Get Several Tracks](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-several-tracks) - * [Get Multiple Artists](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-multiple-artists) - * [Get Multiple Albums](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-multiple-albums) -* Renamed the `auth` parameter of `Spotify.__init__` to `access_token` for better clarity. -* Removed the `client_credentials_manager` and `oauth_manager` parameters because they are redundant. -* Replaced the `set_auth` and `auth_manager` properties with standard attributes. +- Made `CacheHandler` an abstract base class +- Modified the return structure of the `audio_features` function (wrapping the [Get Audio Features for Several Tracks](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-several-audio-features) API) to conform to the return structure of the similar methods listed below. The functions wrapping these APIs do not unwrap the single key JSON response, and this is currently the only function that does this. + - [Get Several Tracks](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-several-tracks) + - [Get Multiple Artists](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-multiple-artists) + - [Get Multiple Albums](https://developer.spotify.com/documentation/web-api/reference/#endpoint-get-multiple-albums) +- Renamed the `auth` parameter of `Spotify.__init__` to `access_token` for better clarity. +- Removed the `client_credentials_manager` and `oauth_manager` parameters because they are redundant. +- Replaced the `set_auth` and `auth_manager` properties with standard attributes. ### Removed -* Removed the following deprecated methods from `Spotify`: - * `playlist_tracks` - * `user_playlist` - * `user_playlist_tracks` - * `user_playlist_change_details` - * `user_playlist_unfollow` - * `user_playlist_add_tracks` - * `user_playlist_replace_tracks` - * `user_playlist_reorder_tracks` - * `user_playlist_remove_all_occurrences_of_tracks` - * `user_playlist_remove_specific_occurrences_of_tracks` - * `user_playlist_follow_playlist` - * `user_playlist_is_following` - -* Removed the deprecated `as_dict` parameter from the `get_access_token` method of `SpotifyOAuth` and `SpotifyPKCE`. -* Removed the deprecated `get_cached_token` and `_save_token_info` methods of `SpotifyOAuth` and `SpotifyPKCE`. -* Removed `SpotifyImplicitGrant`. -* Removed `prompt_for_user_token`. - -## Unreleased [2.x.x] +- Removed the following deprecated methods from `Spotify`: + - `playlist_tracks` + - `user_playlist` + - `user_playlist_tracks` + - `user_playlist_change_details` + - `user_playlist_unfollow` + - `user_playlist_add_tracks` + - `user_playlist_replace_tracks` + - `user_playlist_reorder_tracks` + - `user_playlist_remove_all_occurrences_of_tracks` + - `user_playlist_remove_specific_occurrences_of_tracks` + - `user_playlist_follow_playlist` + - `user_playlist_is_following` + +- Removed the deprecated `as_dict` parameter from the `get_access_token` method of `SpotifyOAuth` and `SpotifyPKCE`. +- Removed the deprecated `get_cached_token` and `_save_token_info` methods of `SpotifyOAuth` and `SpotifyPKCE`. +- Removed `SpotifyImplicitGrant`. +- Removed `prompt_for_user_token`. + + +## Unreleased [2.25.0] +Add your changes below. -- Modified docstring for playlist_add_items() to accept "only URIs or URLs", - with intended deprecation for IDs in v3 +### Added +- Added unit tests for queue functions +- Added detailed function docstrings to 'util.py', including descriptions and special sections that lists arguments, returns, and raises. +- Updated order of instructions for Python and pip package manager installation in TUTORIAL.md +- Updated TUTORIAL.md instructions to match current layout of Spotify Developer Dashboard +- Added test_artist_id, test_artist_url, and test_artists_mixed_ids to non_user_endpoints test.py +- Added rate/request limit to FAQ +- Added custom `urllib3.Retry` class for printing a warning when a rate/request limit is reached. + +### Fixed +- Audiobook integration tests +- Edited docstrings for certain functions in client.py for functions that are no longer in use and have been replaced. +- `current_user_unfollow_playlist()` now supports playlist IDs, URLs, and URIs rather than previously where it only supported playlist IDs. + +### Removed +- `mock` no longer listed as a test dependency. Only built-in `unittest.mock` is actually used. + +## [2.24.0] - 2024-05-30 + +### 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. +- Added `update` field to `current_user_follow_playlist`. + +### Changed +- 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 + +### Added +- Added optional `encoder_cls` argument to `CacheFileHandler`, which overwrite default encoder for token before writing to disk +- Integration tests for searching multiple types in multiple markets (non-user endpoints) +- Publish to PyPI action + +### Fixed +- Fixed the regex for matching playlist URIs with the format spotify:user:USERNAME:playlist:PLAYLISTID. +- `search_markets` now factors the counts of all types in the `total` rather than just the first type ([#534](https://github.com/spotipy-dev/spotipy/issues/534)) + +## [2.22.1] - 2023-01-23 ### Added @@ -57,12 +107,16 @@ Rebasing master onto v3 doesn't require a changelog update. - Added Comment to README - Getting Started for user to add URI to app in Spotify Developer Dashboard. - Added playlist_add_tracks.py to example folder +### Changed + +- Modified docstring for playlist_add_items() to accept "only URIs or URLs", + with intended deprecation for IDs in v3 + ### Fixed +- Path traversal vulnerability that may lead to type confusion in URI handling code - Update contributing.md -### Removed - ## [2.22.0] - 2022-12-10 ### Added @@ -76,7 +130,7 @@ Rebasing master onto v3 doesn't require a changelog update. - 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 @@ -101,13 +155,13 @@ Rebasing master onto v3 doesn't require a changelog update. ### 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 @@ -120,7 +174,7 @@ Rebasing master onto v3 doesn't require a changelog update. ### 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` ### Fixed @@ -165,7 +219,7 @@ Rebasing master onto v3 doesn't require a changelog update. - 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 @@ -266,7 +320,7 @@ Rebasing master onto v3 doesn't require a changelog update. 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 @@ -317,7 +371,7 @@ Rebasing master onto v3 doesn't require a changelog update. - 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 @@ -420,13 +474,16 @@ Rebasing master onto v3 doesn't require a changelog update. - 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 - Fix typos in doc - Start following [SemVer](https://semver.org) properly +### Changed + +- Made instructions in the CONTRIBUTING.md file more clear such that it is easier to onboard and there are no conflicts with TUTORIAL.md ## [2.5.0] - 2020-01-11 Added follow and player endpoints @@ -469,7 +526,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 @@ -513,7 +570,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 e5701b50..11558a0c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ If you would like to contribute to spotipy follow these steps: # Linux or Mac export SPOTIPY_CLIENT_ID=client_id_here export SPOTIPY_CLIENT_SECRET=client_secret_here -export SPOTIPY_CLIENT_USERNAME=client_username_here # This is actually an id not spotify display name +export SPOTIPY_CLIENT_USERNAME=client_username_here # This is actually an id not spotify display name and can be found [here](https://www.spotify.com/us/account/overview/) export SPOTIPY_REDIRECT_URI=http://localhost:8080 # Make url is set in app you created to get your ID and SECRET # Windows @@ -21,9 +21,9 @@ $env:SPOTIPY_REDIRECT_URI="http://localhost:8080" ### Create virtual environment, install dependencies, run tests: ```bash -$ virtualenv --python=python3.7 env +$ virtualenv --python=python3 env $ source env/bin/activate -(env) $ pip install --user -e . +(env) $ pip install -e . (env) $ python -m unittest discover -v tests ``` @@ -44,6 +44,10 @@ To make sure if the import lists are stored correctly: pip install isort isort . -c -v +### Changelog + +Don't forget to add a short description of your change in the [CHANGELOG](CHANGELOG.md) + ### Publishing (by maintainer) - Bump version in setup.py @@ -51,8 +55,7 @@ To make sure if the import lists are stored correctly: - Add to changelog: ## Unreleased - - // Add new changes below + Add your changes below. ### Added @@ -61,17 +64,8 @@ To make sure if the import lists are stored correctly: ### Removed - Commit changes - - Package to pypi: - - python setup.py sdist bdist_wheel - python3 setup.py sdist bdist_wheel - twine check dist/* - twine upload dist/* - + - Push tag to trigger PyPI build & release workflow - Create github release https://github.com/plamere/spotipy/releases with the changelog content for the version and a short name that describes the main addition - Verify doc uses latest https://readthedocs.org/projects/spotipy/ - -### Changelog - -Don't forget to add a short description of your change in the [CHANGELOG](CHANGELOG.md) + \ No newline at end of file diff --git a/FAQ.md b/FAQ.md index 895e2938..f34fa19f 100644 --- a/FAQ.md +++ b/FAQ.md @@ -36,7 +36,7 @@ Error: Solution: - You are likely missing a scope when requesting the endpoint, check -https://developer.spotify.com/web-api/using-scopes/ +https://developer.spotify.com/documentation/web-api/concepts/scopes/ ### Search doesn't find some tracks @@ -51,4 +51,26 @@ must be specified: `search("abba", market="DE")`. If you cannot open a browser, set `open_browser=False` when instantiating SpotifyOAuth or SpotifyPKCE. You will be prompted to open the authorization URI manually. -See the [headless auth example](examples/headless.py). \ No newline at end of file +See the [headless auth example](examples/headless.py). + +### My application is not responding + +This is still speculation, but it seems that Spotify has two limits. A rate limit and a request limit. + +- The rate limit prevents a script from requesting too much from the API in a short period of time. +- The request limit limits how many requests you can make in a 24 hour window. +The limits appear to be endpoint-specific, so each endpoint has its own limits. + +If your application stops responding, it's likely that you've reached the request limit. +There's nothing Spotipy can do to prevent this, but you can follow Spotify's [Rate Limits](https://developer.spotify.com/documentation/web-api/concepts/rate-limits) guide to learn how rate limiting works and what you can do to avoid ever hitting a limit. + +#### *Why* is the application not responding? +Spotipy (or more precisely `urllib3`) has a backoff-retry strategy built in, which is waiting until the rate limit is gone. +If you want to receive an error instead, then you can pass `retries=0` to `Spotify` like this: +```python +sp = spotipy.Spotify( + retries=0, + ... +) +``` +The error raised is a `spotipy.exceptions.SpotifyException` 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..b3dd15dc 100644 --- a/TUTORIAL.md +++ b/TUTORIAL.md @@ -4,18 +4,19 @@ Hello and welcome to the Spotipy Tutorial for Beginners. If you have limited exp ## Prerequisites In order to complete this tutorial successfully, there are a few things that you should already have installed: -**1. pip package manager** +**1. python3** -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/ +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/ +**2. pip package manager** -**2. python3** +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/ -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 -If you see a version number, Python is already installed. If not, you can download it here: https://www.python.org/downloads/ +A. After ensuring that pip is installed, run the following command in Terminal to install Spotipy: pip install spotipy --upgrade -**3. experience with basic Linux commands** +**3. 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. @@ -24,21 +25,21 @@ Once those three setup items are taken care of, you're ready to start learning h ## Step 1. Creating a Spotify Account Spotipy relies on the Spotify API. In order to use the Spotify API, you'll need to create a Spotify developer account. -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. +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, begin by clicking on your profile name at the top right of your screen and then click “Dashboard” to go to Spotify’s 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. Check the box "Accept the Spotify Developer Terms of Service" and then click "Accept the terms". On the next page, verify your email address if you haven't already. Click the "Create an App" button. Enter any name and description you'd like for your new app. Next, add "http://localhost:1234" (or any other port number of your choosing) to the "Redirect URI" secction. Check the box "I understand and agree with Spotify's Developer Terms of Service and Design Guidelines" and then click the "Save" button. -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. - -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. +C. Click on "Settings". Underneath "Client ID", you'll see a "View Client Secret" link. Click the link to reveal your Client secret and copy both your Client secret and your Client ID somewhere so that you can 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. In your new 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 -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 +49,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. +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 1B. ## 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 +79,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. +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..69d99433 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,24 +10,29 @@ # All configuration values have a default; values that are commented out # serve to show the default. -import spotipy import sys import os # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # 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('.')) +sys.path.insert(0, os.path.abspath("..")) + +import spotipy # -- General configuration ----------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' +# needs_sphinx = '1.0' # 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'] @@ -37,7 +41,7 @@ source_suffix = '.rst' # The encoding of source files. -#source_encoding = 'utf-8-sig' +# source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'index' @@ -57,68 +61,68 @@ # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. -#language = None +# language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: -#today = '' +# today = '' # Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' +# today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = ['_build'] # The reST default role (used for this markup: `text`) to use for all documents. -#default_role = None +# default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True +# add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). -#add_module_names = True +# add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. -#show_authors = False +# show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] +# modindex_common_prefix = [] # -- Options for HTML output --------------------------------------------------- # 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 # documentation. -#html_theme_options = {} +# html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] +# html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". -#html_title = None +# html_title = None # A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None +# html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. -#html_logo = None +# html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. -#html_favicon = None +# html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, @@ -127,44 +131,44 @@ # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' +# html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. -#html_use_smartypants = True +# html_use_smartypants = True # Custom sidebar templates, maps document names to template names. -#html_sidebars = {} +# html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. -#html_additional_pages = {} +# html_additional_pages = {} # If false, no module index is generated. -#html_domain_indices = True +# html_domain_indices = True # If false, no index is generated. -#html_use_index = True +# html_use_index = True # If true, the index is split into individual pages for each letter. -#html_split_index = False +# html_split_index = False # If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True +# html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True +# html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True +# html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. -#html_use_opensearch = '' +# html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None +# html_file_suffix = None # Output file base name for HTML help builder. htmlhelp_basename = 'spotipydoc' @@ -192,23 +196,23 @@ # The name of an image file (relative to this directory) to place at the top of # the title page. -#latex_logo = None +# latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. -#latex_use_parts = False +# latex_use_parts = False # If true, show page references after internal links. -#latex_show_pagerefs = False +# latex_show_pagerefs = False # If true, show URL addresses after external links. -#latex_show_urls = False +# latex_show_urls = False # Documents to append as an appendix to all manuals. -#latex_appendices = [] +# latex_appendices = [] # If false, no module index is generated. -#latex_domain_indices = True +# latex_domain_indices = True # -- Options for manual page output -------------------------------------------- @@ -221,7 +225,7 @@ ] # If true, show URL addresses after external links. -#man_show_urls = False +# man_show_urls = False # -- Options for Texinfo output ------------------------------------------------ @@ -236,10 +240,10 @@ ] # Documents to append as an appendix to all manuals. -#texinfo_appendices = [] +# texinfo_appendices = [] # If false, no module index is generated. -#texinfo_domain_indices = True +# texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' +# texinfo_show_urls = 'footnote' diff --git a/docs/index.rst b/docs/index.rst index c9622921..c7ef2bc8 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -5,73 +5,16 @@ Welcome to Spotipy! =================================== *Spotipy* is a lightweight Python library for the `Spotify Web API -`_. With *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), here's 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 ======== *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 `_ documentation. +API `_ documentation. Installation ============ @@ -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,11 +91,12 @@ on Windows):: export SPOTIPY_CLIENT_SECRET='your-spotify-client-secret' export SPOTIPY_REDIRECT_URI='your-app-redirect-url' + Scopes ------ See `Using -Scopes `_ for information +Scopes `_ for information about scopes. Redirect URI @@ -160,7 +113,7 @@ The redirect URI can be any valid URI (it does not need to be accessible) such as ``http://example.com``, ``http://localhost`` or ``http://127.0.0.1:9090``. .. note:: If you choose an `http`-scheme URL, and it's for `localhost` or - `127.0.0.1`, **AND** it specifies a port, then spotispy will instantiate + `127.0.0.1`, **AND** it specifies a port, then spotipy will instantiate a server on the indicated response to receive the access token from the response at the end of the oauth flow [see the code](https://github.com/plamere/spotipy/blob/master/spotipy/oauth2.py#L483-L490). @@ -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,36 +296,12 @@ 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 ========== -Spotipy authored by Paul Lamere (plamere) with contributions by: - - - Daniel Beaudry (`danbeaudry on Github `_) - - Faruk Emre Sahin (`fsahin on Github `_) - - George (`rogueleaderr on Github `_) - - Henry Greville (`sethaurus on Github `_) - - Hugo van Kemanade (`hugovk on Github `_) - - JosĂ© Manuel PĂ©rez (`JMPerez on Github `_) - - Lucas Nunno (`lnunno on Github `_) - - Lynn Root (`econchick on Github `_) - - Matt Dennewitz (`mattdennewitz on Github `_) - - Matthew Duck (`mattduck on Github `_) - - Michael Thelin (`thelinmichael on Github `_) - - Ryan Choi (`ryankicks on Github `_) - - Simon Metson (`drsm79 on Github `_) - - Steve Winton (`swinton on Github `_) - - Tim Balzer (`timbalzer on Github `_) - - `corycorycory on Github `_ - - Nathan Coleman (`nathancoleman on Github `_) - - Michael Birtwell (`mbirtwell on Github `_) - - Harrison Hayes (`Harrison97 on Github `_) - - Stephane Bruckert (`stephanebruckert on Github `_) - - Ritiek Malhotra (`ritiek on Github `_) - If you are a developer with Python experience, and you would like to contribute to Spotipy, please be sure to follow the guidelines listed below: @@ -325,7 +312,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 @@ -391,4 +378,3 @@ Indices and tables * :ref:`genindex` * :ref:`modindex` * :ref:`search` - 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 44e71805..9f13e1fb 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 fda6ce66..c81b23d5 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 b4f19389..ddebda63 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 96d907f9..f689b5ef 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 4656eaae..423af3b8 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/simple1.py b/examples/simple_artist_albums.py similarity index 93% rename from examples/simple1.py rename to examples/simple_artist_albums.py index 8300215b..8b99ce6a 100644 --- a/examples/simple1.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/simple2.py b/examples/simple_artist_top_tracks.py similarity index 99% rename from examples/simple2.py rename to examples/simple_artist_top_tracks.py index 3186c0d0..6943d415 100644 --- a/examples/simple2.py +++ b/examples/simple_artist_top_tracks.py @@ -1,4 +1,3 @@ - from spotipy.oauth2 import SpotifyClientCredentials import spotipy diff --git a/examples/simple4.py b/examples/simple_me.py similarity index 100% rename from examples/simple4.py rename to examples/simple_me.py diff --git a/examples/simple0.py b/examples/simple_search_artist.py similarity index 100% rename from examples/simple0.py rename to examples/simple_search_artist.py diff --git a/examples/simple3.py b/examples/simple_search_artist_image_url.py similarity index 100% rename from examples/simple3.py rename to examples/simple_search_artist_image_url.py diff --git a/examples/title_chain.py b/examples/title_chain.py index 0bdc401d..30ce5a68 100644 --- a/examples/title_chain.py +++ b/examples/title_chain.py @@ -13,7 +13,7 @@ sp = spotipy.Spotify(auth_manager=auth_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 94d403a1..0ce40718 100644 --- a/setup.py +++ b/setup.py @@ -1,19 +1,14 @@ 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( @@ -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 1f4686eb..99f0720a 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 @@ -49,15 +50,18 @@ class CacheFileHandler(CacheHandler): def __init__(self, cache_path=None, - username=None): + username=None, + encoder_cls=None): """ Parameters: * cache_path: May be supplied, will otherwise be generated (takes precedence over `username`) * username: May be supplied or set as environment variable (will set `cache_path` to `.cache-{username}`) + * encoder_cls: May be supplied as a means of overwriting the + default serializer used for writing tokens to disk """ - + self.encoder_cls = encoder_cls if cache_path: self.cache_path = cache_path else: @@ -76,7 +80,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: @@ -87,9 +91,9 @@ def get_cached_token(self): def save_token_to_cache(self, token_info): try: f = open(self.cache_path, "w") - f.write(json.dumps(token_info)) + 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) @@ -204,3 +208,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 55801465..0e5a87d5 100644 --- a/spotipy/client.py +++ b/spotipy/client.py @@ -1,23 +1,23 @@ -# -*- coding: utf-8 -*- - """ A simple and thin Python library for the Spotify Web API """ __all__ = ["Spotify", "SpotifyException"] import json import logging +import re import warnings import requests -import six -import urllib3 from spotipy.exceptions import SpotifyException +from spotipy.util import Retry + +from collections import defaultdict logger = logging.getLogger(__name__) -class Spotify(object): +class Spotify: """ Example usage:: @@ -96,6 +96,33 @@ class Spotify(object): "US", "UY"] + # Spotify URI scheme defined in [1], and the ID format as base-62 in [2]. + # + # 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 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/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/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]+$' + def __init__( self, access_token=None, @@ -170,12 +197,15 @@ def __init__( 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() - retry = urllib3.Retry( + retry = Retry( total=self.retries, connect=None, read=False, @@ -189,15 +219,15 @@ def _build_session(self): self._session.mount('https://', adapter) def _auth_headers(self): - if self.access_token: - return {"Authorization": "Bearer {0}".format(self.access_token)} + if self._auth: + return {"Authorization": f"Bearer {self._auth}"} if not self.auth_manager: return {} try: token = self.auth_manager.get_access_token() 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) @@ -252,7 +282,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, ) @@ -266,7 +296,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: @@ -361,22 +391,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, @@ -555,12 +596,12 @@ def search_markets(self, q, limit=10, offset=0, type="track", markets=None, tota official documentation https://developer.spotify.com/documentation/web-api/reference/search/) # noqa - limit - the number of items to return (min = 1, default = 10, max = 50). If a search is to be done on multiple markets, then this limit is applied to each market. (e.g. search US, CA, MX each with a limit of 10). + If multiple types are specified, this applies to each type. - offset - the index of the first item to return - type - the types of items to return. One or more of 'artist', 'album', 'track', 'playlist', 'show', or 'episode'. If multiple types are desired, pass in a comma separated string. - markets - A list of ISO 3166-1 alpha-2 country codes. Search all country markets by default. - - total - the total number of results to return if multiple markets are supplied in the search. - If multiple types are specified, this only applies to the first type. + - total - the total number of results to return across multiple markets and types. """ warnings.warn( "Searching multiple markets is an experimental feature. " @@ -608,7 +649,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), @@ -636,7 +677,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, @@ -651,7 +692,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 @@ -663,11 +704,60 @@ 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", ) + def user_playlist(self, user, playlist_id=None, fields=None, market=None): + warnings.warn( + "You should use `playlist(playlist_id)` instead", + DeprecationWarning, + ) + + """ Gets a single playlist of a user + + Parameters: + - user - the id of the user + - playlist_id - the id of the playlist + - fields - which fields to return + """ + if playlist_id is None: + return self._get(f"users/{user}/starred") + return self.playlist(playlist_id, fields=fields, market=market) + + def user_playlist_tracks( + self, + user=None, + playlist_id=None, + fields=None, + limit=100, + offset=0, + market=None, + ): + warnings.warn( + "You should use `playlist_tracks(playlist_id)` instead", + DeprecationWarning, + ) + + """ Get full details of the tracks of a playlist owned by a user. + + Parameters: + - user - the id of the user + - playlist_id - the id of the playlist + - fields - which fields to return + - limit - the maximum number of tracks to return + - offset - the index of the first track to return + - market - an ISO 3166-1 alpha-2 country code. + """ + return self.playlist_tracks( + playlist_id, + limit=limit, + offset=offset, + fields=fields, + market=market, + ) + def user_playlists(self, user, limit=50, offset=0): """ Gets playlists of a user @@ -677,7 +767,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=""): @@ -697,7 +787,234 @@ 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, + user, + playlist_id, + name=None, + public=None, + collaborative=None, + description=None, + ): + """ This function is no longer in use, please use the recommended function in the warning! + + Changes a playlist's name and/or public/private state + + Parameters: + - user - the id of the user + - playlist_id - the id of the playlist + - name - optional name of the playlist + - public - optional is the playlist public + - collaborative - optional is the playlist collaborative + - description - optional description of the playlist + """ + warnings.warn( + "You should use `playlist_change_details(playlist_id, ...)` instead", + DeprecationWarning, + ) + + return self.playlist_change_details(playlist_id, name, public, + collaborative, description) + + def user_playlist_unfollow(self, user, playlist_id): + """ This function is no longer in use, please use the recommended function in the warning! + + Unfollows (deletes) a playlist for a user + + Parameters: + - user - the id of the user + - name - the name of the playlist + """ + warnings.warn( + "You should use `current_user_unfollow_playlist(playlist_id)` instead", + DeprecationWarning, + ) + return self.current_user_unfollow_playlist(playlist_id) + + def user_playlist_add_tracks( + self, user, playlist_id, tracks, position=None + ): + """ This function is no longer in use, please use the recommended function in the warning! + + Adds tracks to a playlist + + Parameters: + - user - the id of the user + - playlist_id - the id of the playlist + - tracks - a list of track URIs, URLs or IDs + - position - the position to add the tracks + """ + warnings.warn( + "You should use `playlist_add_items(playlist_id, tracks)` instead", + DeprecationWarning, + ) + + tracks = [self._get_uri("track", tid) for tid in tracks] + return self.playlist_add_items(playlist_id, tracks, position) + + def user_playlist_add_episodes( + self, user, playlist_id, episodes, position=None + ): + """ This function is no longer in use, please use the recommended function in the warning! + + Adds episodes to a playlist + + Parameters: + - user - the id of the user + - playlist_id - the id of the playlist + - episodes - a list of track URIs, URLs or IDs + - position - the position to add the episodes + """ + warnings.warn( + "You should use `playlist_add_items(playlist_id, episodes)` instead", + DeprecationWarning, + ) + + episodes = [self._get_uri("episode", tid) for tid in episodes] + return self.playlist_add_items(playlist_id, episodes, position) + + def user_playlist_replace_tracks(self, user, playlist_id, tracks): + """ This function is no longer in use, please use the recommended function in the warning! + + Replace all tracks in a playlist for a user + + Parameters: + - user - the id of the user + - playlist_id - the id of the playlist + - tracks - the list of track ids to add to the playlist + """ + warnings.warn( + "You should use `playlist_replace_items(playlist_id, tracks)` instead", + DeprecationWarning, + ) + return self.playlist_replace_items(playlist_id, tracks) + + def user_playlist_reorder_tracks( + self, + user, + playlist_id, + range_start, + insert_before, + range_length=1, + snapshot_id=None, + ): + """ This function is no longer in use, please use the recommended function in the warning! + + Reorder tracks in a playlist from a user + + Parameters: + - user - the id of the user + - playlist_id - the id of the playlist + - range_start - the position of the first track to be reordered + - range_length - optional the number of tracks to be reordered + (default: 1) + - insert_before - the position where the tracks should be + inserted + - snapshot_id - optional playlist's snapshot ID + """ + warnings.warn( + "You should use `playlist_reorder_items(playlist_id, ...)` instead", + DeprecationWarning, + ) + return self.playlist_reorder_items(playlist_id, range_start, + insert_before, range_length, + snapshot_id) + + def user_playlist_remove_all_occurrences_of_tracks( + self, user, playlist_id, tracks, snapshot_id=None + ): + """ This function is no longer in use, please use the recommended function in the warning! + + Removes all occurrences of the given tracks from the given playlist + + Parameters: + - user - the id of the user + - playlist_id - the id of the playlist + - tracks - the list of track ids to remove from the playlist + - snapshot_id - optional id of the playlist snapshot + """ + warnings.warn( + "You should use `playlist_remove_all_occurrences_of_items" + "(playlist_id, tracks)` instead", + DeprecationWarning, + ) + return self.playlist_remove_all_occurrences_of_items(playlist_id, + tracks, + snapshot_id) + + def user_playlist_remove_specific_occurrences_of_tracks( + self, user, playlist_id, tracks, snapshot_id=None + ): + """ This function is no longer in use, please use the recommended function in the warning! + + Removes all occurrences of the given tracks from the given playlist + + Parameters: + - user - the id of the user + - playlist_id - the id of the playlist + - tracks - an array of objects containing Spotify URIs of the + tracks to remove with their current positions in the + playlist. For example: + [ { "uri":"4iV5W9uYEdYUVa79Axb7Rh", "positions":[2] }, + { "uri":"1301WleyT98MSxVHPZCA6M", "positions":[7] } ] + - snapshot_id - optional id of the playlist snapshot + """ + warnings.warn( + "You should use `playlist_remove_specific_occurrences_of_items" + "(playlist_id, tracks)` instead", + DeprecationWarning, + ) + plid = self._get_id("playlist", playlist_id) + ftracks = [] + for tr in tracks: + ftracks.append( + { + "uri": self._get_uri("track", tr["uri"]), + "positions": tr["positions"], + } + ) + payload = {"tracks": ftracks} + if snapshot_id: + payload["snapshot_id"] = snapshot_id + return self._delete( + f"users/{user}/playlists/{plid}/tracks", payload=payload + ) + + def user_playlist_follow_playlist(self, playlist_owner_id, playlist_id): + """ This function is no longer in use, please use the recommended function in the warning! + + Add the current authenticated user as a follower of a playlist. + + Parameters: + - playlist_owner_id - the user id of the playlist owner + - playlist_id - the id of the playlist + """ + warnings.warn( + "You should use `current_user_follow_playlist(playlist_id)` instead", + DeprecationWarning, + ) + return self.current_user_follow_playlist(playlist_id) + + def user_playlist_is_following( + self, playlist_owner_id, playlist_id, user_ids + ): + """ This function is no longer in use, please use the recommended function in the warning! + + Check to see if the given users are following the given playlist + + Parameters: + - playlist_owner_id - the user id of the playlist owner + - playlist_id - the id of the playlist + - user_ids - the ids of the users that you want to check to see + if they follow the playlist. Maximum: 5 ids. + """ + warnings.warn( + "You should use `playlist_is_following(playlist_id, user_ids)` instead", + DeprecationWarning, + ) + return self.playlist_is_following(playlist_id, user_ids) def playlist_change_details( self, @@ -719,16 +1036,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): @@ -736,10 +1053,10 @@ def current_user_unfollow_playlist(self, playlist_id): user Parameters: - - name - the name of the playlist + - playlist_id - the id of the playlist """ return self._delete( - "playlists/%s/followers" % (playlist_id) + f"playlists/{self._get_id('playlist', playlist_id)}/followers" ) def playlist_add_items( @@ -755,7 +1072,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, ) @@ -771,7 +1088,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( @@ -802,7 +1119,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( @@ -823,7 +1140,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( @@ -854,10 +1171,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. @@ -866,7 +1183,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( @@ -1407,7 +1725,12 @@ def audio_features(self, tracks=[]): else: tlist = [self._get_id("track", t) for t in tracks] results = self._get("audio-features/?ids=" + ",".join(tlist)) - return results + # the response has changed, look for the new style first, and if + # it's not there, fallback on the old style + if "audio_features" in results: + return results["audio_features"] + else: + return results def devices(self): """ Get a list of user's available devices. @@ -1451,7 +1774,7 @@ def start_playback( ): """ Start or resume user's playback. - Provide a `context_uri` to start playback or an album, + Provide a `context_uri` to start playback of an album, artist, or playlist. Provide a `uris` list to start playback of one or more @@ -1527,7 +1850,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 ) ) @@ -1543,7 +1866,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 ) ) @@ -1562,7 +1885,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, ) ) @@ -1580,7 +1903,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 ) ) @@ -1591,7 +1914,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 @@ -1605,10 +1928,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) @@ -1627,26 +1950,34 @@ 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): - fields = id.split(":") - if len(fields) >= 3: - if type != fields[-2]: - logger.warning('Expected id of type %s but found type %s %s', - type, fields[-2], id) - return fields[-1].split("?")[0] - fields = id.split("/") - if len(fields) >= 3: - itype = fields[-2] - if type != itype: - logger.warning('Expected id of type %s but found type %s %s', - type, itype, id) - return fields[-1].split("?")[0] - return id + uri_match = re.search(Spotify._regex_spotify_uri, id) + if uri_match is not None: + uri_match_groups = uri_match.groupdict() + if uri_match_groups['type'] != type: + # TODO change to a ValueError in v3 + raise SpotifyException(400, -1, "Unexpected Spotify URI type.") + return uri_match_groups['id'] + + url_match = re.search(Spotify._regex_spotify_url, id) + if url_match is not None: + url_match_groups = url_match.groupdict() + if url_match_groups['type'] != type: + raise SpotifyException(400, -1, "Unexpected Spotify URL type.") + # TODO change to a ValueError in v3 + return url_match_groups['id'] + + # Raw identifiers might be passed, ensure they are also base-62 + if re.search(Spotify._regex_base62, id) is not None: + return id + + # TODO change to a ValueError in v3 + raise SpotifyException(400, -1, "Unsupported URL / URI.") def _get_uri(self, type, id): if self._is_uri(id): @@ -1655,7 +1986,7 @@ def _get_uri(self, type, id): return "spotify:" + type + ":" + self._get_id(type, id) def _is_uri(self, uri): - return uri.startswith("spotify:") and len(uri.split(':')) == 3 + return re.search(Spotify._regex_spotify_uri, uri) is not None def _search_multiple_markets(self, q, limit, offset, type, markets, total): if total and limit > total: @@ -1666,22 +1997,77 @@ def _search_multiple_markets(self, q, limit, offset, type, markets, total): UserWarning, ) - results = {} - first_type = type.split(",")[0] + 's' + results = defaultdict(dict) + item_types = [item_type + "s" for item_type in type.split(",")] count = 0 for country in markets: result = self._get( "search", q=q, limit=limit, offset=offset, type=type, market=country ) - results[country] = result + for item_type in item_types: + results[country][item_type] = result[item_type] + + # Truncate the items list to the current limit + if len(results[country][item_type]['items']) > limit: + results[country][item_type]['items'] = \ + results[country][item_type]['items'][:limit] + + count += len(results[country][item_type]['items']) + if total and limit > total - count: + # when approaching `total` results, adjust `limit` to not request more + # items than needed + limit = total - count - count += len(result[first_type]['items']) if total and count >= total: - break - if total and limit > total - count: - # when approaching `total` results, adjust `limit` to not request more - # items than needed - limit = total - count + return results return results + + def get_audiobook(self, id, market=None): + """ Get Spotify catalog information for a single audiobook identified by its unique + Spotify ID. + + Parameters: + - id - the Spotify ID for the audiobook + - market - an ISO 3166-1 alpha-2 country code. + """ + audiobook_id = self._get_id("audiobook", id) + endpoint = f"audiobooks/{audiobook_id}" + + if market: + endpoint += f'?market={market}' + + return self._get(endpoint) + + def get_audiobooks(self, ids, market=None): + """ Get Spotify catalog information for multiple audiobooks based on their Spotify IDs. + + Parameters: + - ids - a list of Spotify IDs for the audiobooks + - market - an ISO 3166-1 alpha-2 country code. + """ + audiobook_ids = [self._get_id("audiobook", id) for id in ids] + endpoint = f"audiobooks?ids={','.join(audiobook_ids)}" + + if market: + endpoint += f'&market={market}' + + return self._get(endpoint) + + def get_audiobook_chapters(self, id, market=None, limit=20, offset=0): + """ Get Spotify catalog information about an audiobook’s chapters. + + Parameters: + - id - the Spotify ID for the audiobook + - market - an ISO 3166-1 alpha-2 country code. + - limit - the maximum number of items to return + - offset - the index of the first item to return + """ + audiobook_id = self._get_id("audiobook", id) + endpoint = f"audiobooks/{audiobook_id}/chapters?limit={limit}&offset={offset}" + + if market: + endpoint += f'&market={market}' + + return self._get(endpoint) 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 20d7fade..5a2fd4ed 100644 --- a/spotipy/oauth2.py +++ b/spotipy/oauth2.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - __all__ = [ "SpotifyClientCredentials", "SpotifyOAuth", @@ -15,11 +13,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 @@ -35,7 +31,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): @@ -44,7 +40,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, @@ -53,25 +49,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 @@ -141,9 +133,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 ) @@ -195,7 +185,7 @@ def __init__( """ - super(SpotifyClientCredentials, self).__init__(requests_session) + super().__init__(requests_session) self.client_id = client_id self.client_secret = client_secret @@ -309,11 +299,11 @@ def __init__( for performance reasons (connection pooling). * 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 """ - super(SpotifyOAuth, self).__init__(requests_session) + super().__init__(requests_session) self.client_id = client_id self.client_secret = client_secret @@ -369,7 +359,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 @@ -388,8 +378,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"]) @@ -566,7 +555,7 @@ class SpotifyPKCE(SpotifyAuthBase): """ Implements PKCE Authorization Flow for client apps This auth manager enables *user and non-user* endpoints with only - a client secret, redirect uri, and username. When the app requests + a client ID, redirect URI, and username. When the app requests an access token for the first time, the user is prompted to authorize the new client app. After authorizing the app, the client app is then given both access and refresh tokens. This is the @@ -596,9 +585,18 @@ def __init__( * client_id: Must be supplied or set as environment variable * redirect_uri: Must be supplied or set as environment variable * state: Optional, no verification is performed - * scope: Optional, either a string of scopes, or an iterable with elements of type - `Scope` or `str`. E.g., - {Scope.user_modify_playback_state, Scope.user_library_read} + * scope: Optional, either a list of scopes or comma separated string of scopes. + e.g, "playlist-read-private,playlist-read-collaborative" + * cache_path: (deprecated) Optional, will otherwise be generated + (takes precedence over `username`) + * username: (deprecated) Optional or set as environment variable + (will set `cache_path` to `.cache-{username}`) + * proxies: Optional, proxy for the requests library to route through + * 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 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. Optional, will otherwise use `CacheFileHandler`. @@ -613,7 +611,7 @@ def __init__( authorize a user """ - super(SpotifyPKCE, self).__init__(requests_session) + super().__init__(requests_session) self.client_id = client_id self.redirect_uri = redirect_uri self.state = state @@ -645,15 +643,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 @@ -684,7 +675,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) @@ -735,7 +726,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") @@ -894,6 +885,284 @@ def parse_response_code(self, url): def parse_auth_response_url(url): return SpotifyOAuth.parse_auth_response_url(url) + def get_cached_token(self): + warnings.warn("Calling get_cached_token directly on the SpotifyPKCE object will be " + + "deprecated. Instead, please specify a CacheFileHandler instance as " + + "the cache_handler in SpotifyOAuth and use the CacheFileHandler's " + + "get_cached_token method. You can replace:\n\tsp.get_cached_token()" + + "\n\nWith:\n\tsp.validate_token(sp.cache_handler.get_cached_token())", + DeprecationWarning + ) + return self.validate_token(self.cache_handler.get_cached_token()) + + def _save_token_info(self, token_info): + warnings.warn("Calling _save_token_info directly on the SpotifyOAuth object will be " + + "deprecated. Instead, please specify a CacheFileHandler instance as " + + "the cache_handler in SpotifyOAuth and use the CacheFileHandler's " + + "save_token_to_cache method.", + DeprecationWarning + ) + self.cache_handler.save_token_to_cache(token_info) + return None + + +class SpotifyImplicitGrant(SpotifyAuthBase): + """ Implements Implicit Grant Flow for client apps + + This auth manager enables *user and non-user* endpoints with only + a client secret, redirect uri, and username. The user will need to + copy and paste a URI from the browser every hour. + + Security Warning + ----------------- + The OAuth standard no longer recommends the Implicit Grant Flow for + client-side code. Spotify has implemented the OAuth-suggested PKCE + extension that removes the need for a client secret in the + Authentication Code flow. Use the SpotifyPKCE auth manager instead + of SpotifyImplicitGrant. + + SpotifyPKCE contains all the functionality of + SpotifyImplicitGrant, plus automatic response retrieval and + refreshable tokens. Only a few replacements need to be made: + + * get_auth_response()['access_token'] -> + get_access_token(get_authorization_code()) + * get_auth_response() -> + get_access_token(get_authorization_code()); get_cached_token() + * parse_response_token(url)['access_token'] -> + get_access_token(parse_response_code(url)) + * parse_response_token(url) -> + get_access_token(parse_response_code(url)); get_cached_token() + + The security concern in the Implicit Grant flow is that the token is + returned in the URL and can be intercepted through the browser. A + request with an authorization code and proof of origin could not be + easily intercepted without a compromised network. + """ + OAUTH_AUTHORIZE_URL = "https://accounts.spotify.com/authorize" + + def __init__(self, + client_id=None, + redirect_uri=None, + state=None, + scope=None, + cache_path=None, + username=None, + show_dialog=False, + cache_handler=None): + """ Creates Auth Manager using the Implicit Grant flow + + **See help(SpotifyImplicitGrant) for full Security Warning** + + Parameters + ---------- + * client_id: Must be supplied or set as environment variable + * redirect_uri: Must be supplied or set as environment variable + * state: May be supplied, no verification is performed + * scope: Optional, either a list of scopes or comma separated string of scopes. + e.g, "playlist-read-private,playlist-read-collaborative" + * cache_handler: An instance of the `CacheHandler` class to handle + getting and saving cached authorization tokens. + May be supplied, will otherwise use `CacheFileHandler`. + (takes precedence over `cache_path` and `username`) + * cache_path: (deprecated) May be supplied, will otherwise be generated + (takes precedence over `username`) + * username: (deprecated) May be supplied or set as environment variable + (will set `cache_path` to `.cache-{username}`) + * show_dialog: Interpreted as boolean + """ + logger.warning("The OAuth standard no longer recommends the Implicit " + "Grant Flow for client-side code. Use the SpotifyPKCE " + "auth manager instead of SpotifyImplicitGrant. For " + "more details and a guide to switching, see " + "help(SpotifyImplicitGrant).") + + self.client_id = client_id + self.redirect_uri = redirect_uri + self.state = state + if username or cache_path: + warnings.warn("Specifying cache_path or username as arguments to " + + "SpotifyImplicitGrant will be deprecated. Instead, please create " + + "a CacheFileHandler instance with the desired cache_path and " + + "username and pass it to SpotifyImplicitGrant as the " + + "cache_handler. For example:\n\n" + + "\tfrom spotipy.oauth2 import CacheFileHandler\n" + + "\thandler = CacheFileHandler(cache_path=cache_path, " + + "username=username)\n" + + "\tsp = spotipy.SpotifyImplicitGrant(client_id, client_secret, " + + "redirect_uri, cache_handler=handler)", + DeprecationWarning + ) + if cache_handler: + warnings.warn("A cache_handler has been specified along with a cache_path or " + + "username. The cache_path and username arguments will be ignored.") + if cache_handler: + assert issubclass(type(cache_handler), CacheHandler), \ + "type(cache_handler): " + str(type(cache_handler)) + " != " + str(CacheHandler) + self.cache_handler = cache_handler + else: + username = (username or os.getenv(CLIENT_CREDS_ENV_VARS["client_username"])) + self.cache_handler = CacheFileHandler( + username=username, + cache_path=cache_path + ) + self.scope = self._normalize_scope(scope) + self.show_dialog = show_dialog + self._session = None # As to not break inherited __del__ + + def validate_token(self, token_info): + if token_info is None: + return None + + # if scopes don't match, then bail + if "scope" not in token_info or not self._is_scope_subset( + self.scope, token_info["scope"] + ): + return None + + if self.is_token_expired(token_info): + return None + + return token_info + + def get_access_token(self, + state=None, + response=None, + check_cache=True): + """ Gets Auth Token from cache (preferred) or user interaction + + Parameters + ---------- + * state: May be given, overrides (without changing) self.state + * response: URI with token, can break expiration checks + * check_cache: Interpreted as boolean + """ + if check_cache: + token_info = self.validate_token(self.cache_handler.get_cached_token()) + if not (token_info is None or self.is_token_expired(token_info)): + return token_info["access_token"] + + if response: + token_info = self.parse_response_token(response) + else: + token_info = self.get_auth_response(state) + token_info = self._add_custom_values_to_token_info(token_info) + self.cache_handler.save_token_to_cache(token_info) + + return token_info["access_token"] + + def get_authorize_url(self, state=None): + """ Gets the URL to use to authorize this app """ + payload = { + "client_id": self.client_id, + "response_type": "token", + "redirect_uri": self.redirect_uri, + } + if self.scope: + payload["scope"] = self.scope + if state is None: + state = self.state + if state is not None: + payload["state"] = state + if self.show_dialog: + payload["show_dialog"] = True + + urlparams = urllibparse.urlencode(payload) + + return f"{self.OAUTH_AUTHORIZE_URL}?{urlparams}" + + def parse_response_token(self, url, state=None): + """ Parse the response code in the given response url """ + remote_state, token, t_type, exp_in = self.parse_auth_response_url(url) + if state is None: + state = self.state + if state is not None and remote_state != state: + raise SpotifyStateError(state, remote_state) + return {"access_token": token, "token_type": t_type, + "expires_in": exp_in, "state": state} + + @staticmethod + def parse_auth_response_url(url): + url_components = urlparse(url) + fragment_s = url_components.fragment + query_s = url_components.query + form = dict(i.split('=') for i + in (fragment_s or query_s or url).split('&')) + if "error" in form: + 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"]) + return tuple(form.get(param) for param in ["state", "access_token", + "token_type", "expires_in"]) + + def _open_auth_url(self, state=None): + auth_url = self.get_authorize_url(state) + try: + webbrowser.open(auth_url) + logger.info("Opened %s in your browser", auth_url) + except webbrowser.Error: + logger.error("Please navigate here: %s", auth_url) + + def get_auth_response(self, state=None): + """ Gets a new auth **token** with user interaction """ + logger.info('User authentication requires interaction with your ' + 'web browser. Once you enter your credentials and ' + 'give authorization, you will be redirected to ' + 'a url. Paste that url you were directed to to ' + 'complete the authorization.') + + redirect_info = urlparse(self.redirect_uri) + redirect_host, redirect_port = get_host_port(redirect_info.netloc) + # Implicit Grant tokens are returned in a hash fragment + # which is only available to the browser. Therefore, interactive + # URL retrieval is required. + if (redirect_host in ("127.0.0.1", "localhost") + and redirect_info.scheme == "http" and redirect_port): + logger.warning('Using a local redirect URI with a ' + 'port, likely expecting automatic ' + 'retrieval. Due to technical limitations, ' + 'the authentication token cannot be ' + 'automatically retrieved and must be ' + 'copied and pasted.') + + self._open_auth_url(state) + logger.info('Paste that url you were directed to in order to ' + 'complete the authorization') + response = SpotifyImplicitGrant._get_user_input("Enter the URL you " + "were redirected to: ") + return self.parse_response_token(response, state) + + def _add_custom_values_to_token_info(self, token_info): + """ + Store some values that aren't directly provided by a Web API + response. + """ + token_info["expires_at"] = int(time.time()) + token_info["expires_in"] + token_info["scope"] = self.scope + return token_info + + def get_cached_token(self): + warnings.warn("Calling get_cached_token directly on the SpotifyImplicitGrant " + + "object will be deprecated. Instead, please specify a " + + "CacheFileHandler instance as the cache_handler in SpotifyOAuth " + + "and use the CacheFileHandler's get_cached_token method. " + + "You can replace:\n\tsp.get_cached_token()" + + "\n\nWith:\n\tsp.validate_token(sp.cache_handler.get_cached_token())", + DeprecationWarning + ) + return self.validate_token(self.cache_handler.get_cached_token()) + + def _save_token_info(self, token_info): + warnings.warn("Calling _save_token_info directly on the SpotifyImplicitGrant " + + "object will be deprecated. Instead, please specify a " + + "CacheFileHandler instance as the cache_handler in SpotifyOAuth " + + "and use the CacheFileHandler's save_token_to_cache method.", + DeprecationWarning + ) + self.cache_handler.save_token_to_cache(token_info) + return None + class RequestHandler(BaseHTTPRequestHandler): def do_GET(self): @@ -912,7 +1181,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 d76c29f1..9602e7c4 100644 --- a/spotipy/util.py +++ b/spotipy/util.py @@ -1,10 +1,17 @@ -# -*- coding: utf-8 -*- +from __future__ import annotations -""" Shows a user's playlists (need to be authenticated via oauth) """ +""" Shows a user's playlists. This needs to be authenticated via OAuth. """ __all__ = ["CLIENT_CREDS_ENV_VARS"] import logging +import os +import warnings +from types import TracebackType + +import spotipy + +import urllib3 LOGGER = logging.getLogger(__name__) @@ -16,7 +23,98 @@ } +def prompt_for_user_token( + username=None, + scope=None, + client_id=None, + client_secret=None, + redirect_uri=None, + cache_path=None, + oauth_manager=None, + show_dialog=False +): + warnings.warn( + "'prompt_for_user_token' is deprecated." + "Use the following instead: " + " auth_manager=SpotifyOAuth(scope=scope)" + " spotipy.Spotify(auth_manager=auth_manager)", + DeprecationWarning + ) + """Prompt the user to login if necessary and returns a user token + suitable for use with the spotipy.Spotify constructor. + + Parameters: + - username - the Spotify username. (optional) + - scope - the desired scope of the request. (optional) + - client_id - the client ID of your app. (required) + - client_secret - the client secret of your app. (required) + - redirect_uri - the redirect URI of your app. (required) + - cache_path - path to location to save tokens. (required) + - oauth_manager - OAuth manager object. (optional) + - show_dialog - If True, a login prompt always shows or defaults to False. (optional) + """ + if not oauth_manager: + if not client_id: + client_id = os.getenv("SPOTIPY_CLIENT_ID") + + if not client_secret: + client_secret = os.getenv("SPOTIPY_CLIENT_SECRET") + + if not redirect_uri: + redirect_uri = os.getenv("SPOTIPY_REDIRECT_URI") + + if not client_id: + LOGGER.warning( + """ + You need to set your Spotify API credentials. + You can do this by setting environment variables like so: + + export SPOTIPY_CLIENT_ID='your-spotify-client-id' + export SPOTIPY_CLIENT_SECRET='your-spotify-client-secret' + export SPOTIPY_REDIRECT_URI='your-app-redirect-url' + + Get your credentials at + https://developer.spotify.com/my-applications + """ + ) + raise spotipy.SpotifyException(550, -1, "no credentials set") + + sp_oauth = oauth_manager or spotipy.SpotifyOAuth( + client_id, + client_secret, + redirect_uri, + scope=scope, + cache_path=cache_path, + username=username, + show_dialog=show_dialog + ) + + # try to get a valid token for this user, from the cache, + # if not in the cache, then create a new (this will send + # the user to a web page where they can authorize this app) + + token_info = sp_oauth.validate_token(sp_oauth.cache_handler.get_cached_token()) + + if not token_info: + code = sp_oauth.get_auth_response() + token = sp_oauth.get_access_token(code, as_dict=False) + else: + return token_info["access_token"] + + # Auth'ed API request + if token: + return token + else: + return None + + def get_host_port(netloc): + """ Split the network location string into host and port and returns a tuple + where the host is a string and the the port is an integer. + + Parameters: + - netloc - a string representing the network location. + """ if ":" in netloc: host, port = netloc.split(":", 1) port = int(port) @@ -28,6 +126,14 @@ def get_host_port(netloc): def normalize_scope(scope): + """Normalize the scope to verify that it is a list or tuple. A string + input will split the string by commas to create a list of scopes. + A list or tuple input is used directly. + + Parameters: + - scope - a string representing scopes separated by commas, + or a list/tuple of scopes. + """ if scope: if isinstance(scope, str): scopes = scope.split(',') @@ -36,8 +142,34 @@ def normalize_scope(scope): else: raise Exception( "Unsupported scope value, please either provide a list of scopes, " - "or a string of scopes separated by commas" + "or a string of scopes separated by commas." ) return " ".join(sorted(scopes)) else: - return None \ No newline at end of file + return None + + +class Retry(urllib3.Retry): + """ + Custom class for printing a warning when a rate/request limit is reached. + """ + def increment( + self, + method: str | None = None, + url: str | None = None, + response: urllib3.BaseHTTPResponse | None = None, + error: Exception | None = None, + _pool: urllib3.connectionpool.ConnectionPool | None = None, + _stacktrace: TracebackType | None = None, + ) -> urllib3.Retry: + if response: + retry_header = response.headers.get("Retry-After") + if self.is_retry(method, response.status, bool(retry_header)): + logging.warning("Your application has reached a rate/request limit. " + f"Retry will occur after: {retry_header}") + return super().increment(method, + url, + response=response, + error=error, + _pool=_pool, + _stacktrace=_stacktrace) diff --git a/tests/integration/non_user_endpoints/test.py b/tests/integration/non_user_endpoints/test.py index 18a31044..ad8b2196 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, @@ -39,12 +37,19 @@ class AuthTestSpotipy(unittest.TestCase): creep_urn = 'spotify:track:6b2oQwSGFkzsMtQruIWm2p' creep_id = '6b2oQwSGFkzsMtQruIWm2p' creep_url = 'http://open.spotify.com/track/6b2oQwSGFkzsMtQruIWm2p' + el_scorcho_urn = 'spotify:track:0Svkvt5I79wficMFgaqEQJ' el_scorcho_bad_urn = 'spotify:track:0Svkvt5I79wficMFgaqEQK' pinkerton_urn = 'spotify:album:04xe676vyiTeYNXw15o9jT' weezer_urn = 'spotify:artist:3jOstUTkEu2JkjvRdBA5Gu' + pablo_honey_urn = 'spotify:album:6AZv3m27uyRxi8KyJSfUxL' radiohead_urn = 'spotify:artist:4Z8W4fKeB5YxbusRsdQVPb' + radiohead_id = "4Z8W4fKeB5YxbusRsdQVPb" + radiohead_url = "https://open.spotify.com/artist/4Z8W4fKeB5YxbusRsdQVPb" + + qotsa_url = "https://open.spotify.com/artist/4pejUc4iciQfgdX6OKulQn" + angeles_haydn_urn = 'spotify:album:1vAbqAeuJVWNAe7UR00bdM' heavyweight_urn = 'spotify:show:5c26B28vZMN8PG0Nppmn5G' heavyweight_id = '5c26B28vZMN8PG0Nppmn5G' @@ -55,6 +60,13 @@ class AuthTestSpotipy(unittest.TestCase): heavyweight_ep1_url = 'https://open.spotify.com/episode/68kq3bNz6hEuq8NtdfwERG' reply_all_ep1_urn = 'spotify:episode:1KHjbpnmNpFmNTczQmTZlR' + dune_urn = 'spotify:audiobook:7iHfbu1YPACw6oZPAFJtqe' + dune_id = '7iHfbu1YPACw6oZPAFJtqe' + dune_url = 'https://open.spotify.com/audiobook/7iHfbu1YPACw6oZPAFJtqe' + two_books = [ + 'spotify:audiobook:7iHfbu1YPACw6oZPAFJtqe', + 'spotify:audiobook:67VtmjZitn25TWocsyAEyh'] + @classmethod def setUpClass(self): self.spotify = Spotify( @@ -94,11 +106,24 @@ def test_artist_urn(self): artist = self.spotify.artist(self.radiohead_urn) self.assertTrue(artist['name'] == 'Radiohead') + def test_artist_url(self): + artist = self.spotify.artist(self.radiohead_url) + self.assertTrue(artist['name'] == 'Radiohead') + + def test_artist_id(self): + artist = self.spotify.artist(self.radiohead_id) + self.assertTrue(artist['name'] == 'Radiohead') + def test_artists(self): results = self.spotify.artists([self.weezer_urn, self.radiohead_urn]) self.assertTrue('artists' in results) self.assertTrue(len(results['artists']) == 2) + def test_artists_mixed_ids(self): + results = self.spotify.artists([self.weezer_urn, self.radiohead_id, self.qotsa_url]) + self.assertTrue('artists' in results) + self.assertTrue(len(results['artists']) == 3) + def test_album_urn(self): album = self.spotify.album(self.pinkerton_urn) self.assertTrue(album['name'] == 'Pinkerton') @@ -222,6 +247,87 @@ def test_artist_search_with_multiple_markets(self): total_limited_results += len(results_limited[country]['artists']['items']) self.assertTrue(total_limited_results <= total) + def test_multiple_types_search_with_multiple_markets(self): + total = 14 + + countries_list = ['GB', 'US', 'AU'] + countries_tuple = ('GB', 'US', 'AU') + + results_multiple = self.spotify.search_markets(q='abba', type='artist,track', + markets=countries_list) + results_all = self.spotify.search_markets(q='abba', type='artist,track') + results_tuple = self.spotify.search_markets(q='abba', type='artist,track', + markets=countries_tuple) + results_limited = self.spotify.search_markets(q='abba', limit=3, type='artist,track', + markets=countries_list, total=total) + + # Asserts 'artists' property is present in all responses + self.assertTrue( + all('artists' in results_multiple[country] for country in results_multiple)) + self.assertTrue(all('artists' in results_all[country] for country in results_all)) + self.assertTrue(all('artists' in results_tuple[country] for country in results_tuple)) + self.assertTrue(all('artists' in results_limited[country] for country in results_limited)) + + # Asserts 'tracks' property is present in all responses + self.assertTrue( + all('tracks' in results_multiple[country] for country in results_multiple)) + self.assertTrue(all('tracks' in results_all[country] for country in results_all)) + self.assertTrue(all('tracks' in results_tuple[country] for country in results_tuple)) + self.assertTrue(all('tracks' in results_limited[country] for country in results_limited)) + + # Asserts 'artists' list is nonempty in unlimited searches + self.assertTrue( + all(len(results_multiple[country]['artists']['items']) > 0 for country in + results_multiple)) + self.assertTrue(all(len(results_all[country]['artists'] + ['items']) > 0 for country in results_all)) + self.assertTrue( + all(len(results_tuple[country]['artists']['items']) > 0 for country in results_tuple)) + + # Asserts 'tracks' list is nonempty in unlimited searches + self.assertTrue( + all(len(results_multiple[country]['tracks']['items']) > 0 for country in + results_multiple)) + self.assertTrue(all(len(results_all[country]['tracks'] + ['items']) > 0 for country in results_all)) + self.assertTrue(all(len(results_tuple[country]['tracks'] + ['items']) > 0 for country in results_tuple)) + + # Asserts artist name is the first artist result in all searches + self.assertTrue(all(results_multiple[country]['artists']['items'] + [0]['name'] == 'ABBA' for country in results_multiple)) + self.assertTrue(all(results_all[country]['artists']['items'] + [0]['name'] == 'ABBA' for country in results_all)) + self.assertTrue(all(results_tuple[country]['artists']['items'] + [0]['name'] == 'ABBA' for country in results_tuple)) + self.assertTrue(all(results_limited[country]['artists']['items'] + [0]['name'] == 'ABBA' for country in results_limited)) + + # Asserts track name is present in responses from specified markets + self.assertTrue(all('Dancing Queen' in + [item['name'] for item in results_multiple[country]['tracks']['items']] + for country in results_multiple)) + self.assertTrue(all('Dancing Queen' in + [item['name'] for item in results_tuple[country]['tracks']['items']] + for country in results_tuple)) + + # Asserts expected number of items are returned based on the total + # 3 artists + 3 tracks = 6 items returned from first market + # 3 artists + 3 tracks = 6 items returned from second market + # 2 artists + 0 tracks = 2 items returned from third market + # 14 items returned total + self.assertEqual(len(results_limited['GB']['artists']['items']), 3) + self.assertEqual(len(results_limited['GB']['tracks']['items']), 3) + self.assertEqual(len(results_limited['US']['artists']['items']), 3) + self.assertEqual(len(results_limited['US']['tracks']['items']), 3) + self.assertEqual(len(results_limited['AU']['artists']['items']), 2) + self.assertEqual(len(results_limited['AU']['tracks']['items']), 0) + + item_count = sum([len(market_result['artists']['items']) + len(market_result['tracks'] + ['items']) for market_result in results_limited.values()]) + + self.assertEqual(item_count, total) + def test_artist_albums(self): results = self.spotify.artist_albums(self.weezer_urn) self.assertTrue('items' in results) @@ -229,7 +335,7 @@ def test_artist_albums(self): def find_album(): for album in results['items']: - if album['name'] == 'Death to False Metal': + if 'Weezer' in album['name']: # Weezer has many albums containing Weezer return True return False @@ -373,3 +479,28 @@ def test_available_markets(self): self.assertTrue(isinstance(markets, list)) self.assertIn("US", markets) self.assertIn("GB", markets) + + def test_get_audiobook(self): + audiobook = self.spotify.get_audiobook(self.dune_urn, market="US") + self.assertTrue(audiobook['name'] == + 'Dune: Book One in the Dune Chronicles') + + def test_get_audiobook_bad_urn(self): + with self.assertRaises(SpotifyException): + self.spotify.get_audiobook("bogus_urn", market="US") + + def test_get_audiobooks(self): + results = self.spotify.get_audiobooks(self.two_books, market="US") + self.assertTrue('audiobooks' in results) + self.assertTrue(len(results['audiobooks']) == 2) + self.assertTrue(results['audiobooks'][0]['name'] + == 'Dune: Book One in the Dune Chronicles') + self.assertTrue(results['audiobooks'][1]['name'] == 'The Helper') + + def test_get_audiobook_chapters(self): + results = self.spotify.get_audiobook_chapters( + self.dune_urn, market="US", limit=10, offset=5) + self.assertTrue('items' in results) + self.assertTrue(len(results['items']) == 10) + self.assertTrue(results['items'][0]['chapter_number'] == 5) + self.assertTrue(results['items'][9]['chapter_number'] == 14) diff --git a/tests/integration/user_endpoints/test.py b/tests/integration/user_endpoints/test.py index 366784a0..a2bf6c89 100644 --- a/tests/integration/user_endpoints/test.py +++ b/tests/integration/user_endpoints/test.py @@ -255,7 +255,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) @@ -268,6 +268,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): @@ -523,3 +536,44 @@ def test_current_user(self): c_user = self.spotify.current_user() user = self.spotify.user(c_user['id']) self.assertEqual(c_user['display_name'], user['display_name']) + + +class SpotifyQueueApiTests(unittest.TestCase): + + @classmethod + def setUp(self): + self.spotify = Spotify(auth="test_token") + + def test_get_queue(self, mock_get): + # Mock the response from _get + mock_get.return_value = {'songs': ['song1', 'song2']} + + # Call the queue function + response = self.spotify.queue() + + # Check if the correct endpoint is called + mock_get.assert_called_with("me/player/queue") + + # Check if the response is as expected + self.assertEqual(response, {'songs': ['song1', 'song2']}) + + def test_add_to_queue(self, mock_post): + test_uri = 'spotify:track:123' + + # Call the add_to_queue function + self.spotify.add_to_queue(test_uri) + + # Check if the correct endpoint is called + endpoint = "me/player/queue?uri=%s" % test_uri + mock_post.assert_called_with(endpoint) + + def test_add_to_queue_with_device_id(self, mock_post): + test_uri = 'spotify:track:123' + device_id = 'device123' + + # Call the add_to_queue function with a device_id + self.spotify.add_to_queue(test_uri, device_id=device_id) + + # Check if the correct endpoint is called + endpoint = "me/player/queue?uri=%s&device_id=%s" % (test_uri, device_id) + mock_post.assert_called_with(endpoint) diff --git a/tests/unit/test_oauth.py b/tests/unit/test_oauth.py index 3f432b1d..2efdfc4d 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, SpotifyPKCE from spotipy.oauth2 import SpotifyClientCredentials, SpotifyOauthError from spotipy.oauth2 import SpotifyStateError from spotipy import MemoryCacheHandler, CacheFileHandler -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