├── tests ├── __init__.py ├── unit │ ├── __init__.py │ └── test_oauth.py ├── integration │ ├── __init__.py │ ├── non_user_endpoints │ │ ├── __init__.py │ │ └── test.py │ └── user_endpoints │ │ ├── __init__.py │ │ └── test.py └── helpers.py ├── MANIFEST.in ├── docs ├── requirements.txt ├── images │ └── spotify-web-api-doc.jpg ├── make.bat ├── Makefile ├── conf.py └── index.rst ├── .gitmodules ├── spotipy ├── __init__.py ├── exceptions.py ├── util.py └── cache_handler.py ├── tox.ini ├── .readthedocs.yaml ├── .github ├── ISSUE_TEMPLATE │ ├── help.md │ ├── feature_request.md │ └── bug_report.md ├── SECURITY.md ├── workflows │ ├── pull_request.yml │ ├── lint.yml │ ├── unit_tests.yml │ ├── integration_tests.yml │ └── publish.yml └── dependabot.yml ├── .gitignore ├── LICENSE.md ├── setup.py ├── CONTRIBUTING.md ├── CODE_OF_CONDUCT.md ├── FAQ.md ├── README.md ├── TUTORIAL.md └── CHANGELOG.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/integration/non_user_endpoints/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/integration/user_endpoints/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.txt *.md 2 | recursive-include docs *.txt 3 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | Sphinx~=8.1.3 2 | sphinx-rtd-theme~=3.0.2 3 | redis>=3.5.3 4 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "examples"] 2 | path = examples 3 | url = git@github.com:spotipy-dev/spotipy-examples.git 4 | -------------------------------------------------------------------------------- /docs/images/spotify-web-api-doc.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spotipy-dev/spotipy/HEAD/docs/images/spotify-web-api-doc.jpg -------------------------------------------------------------------------------- /spotipy/__init__.py: -------------------------------------------------------------------------------- 1 | from .cache_handler import * # noqa 2 | from .client import * # noqa 3 | from .exceptions import * # noqa 4 | from .oauth2 import * # noqa 5 | from .util import * # noqa 6 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py3{8,9,10,11,12} 3 | [testenv] 4 | deps= 5 | requests 6 | commands=python -m unittest discover -v tests/unit 7 | [flake8] 8 | max-line-length = 99 9 | exclude= 10 | .git, 11 | .venv, 12 | build, 13 | dist, 14 | docs, 15 | examples, 16 | spotipy.egg-info -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file for Sphinx projects 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | version: 2 5 | 6 | build: 7 | os: ubuntu-22.04 8 | tools: 9 | python: "3.12" 10 | 11 | sphinx: 12 | configuration: docs/conf.py 13 | 14 | python: 15 | install: 16 | - requirements: docs/requirements.txt 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/help.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Help 3 | about: I have a question 4 | title: '' 5 | labels: question 6 | assignees: '' 7 | 8 | --- 9 | 10 | 18 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | | ------- | ------------------ | 7 | | 2.x | :white_check_mark: | 8 | | 1.x | :x: | 9 | 10 | ## Reporting a Vulnerability 11 | 12 | Report via https://github.com/spotipy-dev/spotipy/security/advisories. 13 | 14 | Guidance: https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability. 15 | -------------------------------------------------------------------------------- /tests/helpers.py: -------------------------------------------------------------------------------- 1 | import base64 2 | 3 | import requests 4 | 5 | 6 | def get_spotify_playlist(spotify_object, playlist_name, username): 7 | playlists = spotify_object.user_playlists(username) 8 | while playlists: 9 | for item in playlists['items']: 10 | if item['name'] == playlist_name: 11 | return item 12 | playlists = spotify_object.next(playlists) 13 | 14 | 15 | def get_as_base64(url): 16 | return base64.b64encode(requests.get(url).content).decode("utf-8") 17 | -------------------------------------------------------------------------------- /.github/workflows/pull_request.yml: -------------------------------------------------------------------------------- 1 | name: "Pull Request Workflow" 2 | on: 3 | pull_request: 4 | types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled] 5 | 6 | jobs: 7 | # Enforces the update of a changelog file on every pull request 8 | changelog: 9 | runs-on: ubuntu-22.04 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: dangoslen/changelog-enforcer@v3.6.1 13 | with: 14 | changeLogPath: 'CHANGELOG.md' 15 | skipLabel: 'skip-changelog' 16 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-22.04 8 | steps: 9 | - uses: actions/checkout@v4 10 | - name: Set up Python 11 | uses: actions/setup-python@v5 12 | with: 13 | python-version: "3.x" # Lint can be done on latest Python only 14 | - name: Install dependencies 15 | run: | 16 | python -m pip install --upgrade pip 17 | pip install .[test] 18 | - name: Check pep8 with flake8 19 | run: | 20 | flake8 . --count --show-source --statistics 21 | - name: Check sorted imports with isort 22 | run: | 23 | isort . -c 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or code about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/unit_tests.yml: -------------------------------------------------------------------------------- 1 | name: Unit tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-22.04 8 | strategy: 9 | matrix: 10 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Set up Python ${{ matrix.python-version }} 14 | uses: actions/setup-python@v5 15 | with: 16 | python-version: ${{ matrix.python-version }} 17 | - name: Install dependencies 18 | run: | 19 | python -m pip install --upgrade pip 20 | pip install . 21 | - name: Run unit tests 22 | run: | 23 | python -m unittest discover -v tests/unit 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | bin/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # Installer logs 26 | pip-log.txt 27 | pip-delete-this-directory.txt 28 | 29 | # Unit test / coverage reports 30 | htmlcov/ 31 | .tox/ 32 | .coverage 33 | nosetests.xml 34 | coverage.xml 35 | 36 | # Translations 37 | *.mo 38 | 39 | # Mr Developer 40 | .mr.developer.cfg 41 | .project 42 | .pydevproject 43 | 44 | # Rope 45 | .ropeproject 46 | 47 | # Django stuff: 48 | *.log 49 | *.pot 50 | 51 | # Sphinx documentation 52 | docs/_build/ 53 | 54 | # Spotipy tokens 55 | .cache 56 | 57 | .* 58 | archive -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **Your code** 14 | Share a complete minimal working example. 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Output** 20 | Paste and format errors (with complete stacktrace) or logs. Make sure to remove sensitive information. 21 | 22 | **Environment:** 23 | - OS: [e.g. Windows, Mac] 24 | - Python version [e.g. 3.7.0] 25 | - spotipy version [e.g. 2.12.0] 26 | - your IDE (if using any) [e.g. PyCharm, Jupyter Notebook IDE, Google Colab] 27 | 28 | **Additional context** 29 | Add any other context about the problem here. 30 | -------------------------------------------------------------------------------- /.github/workflows/integration_tests.yml: -------------------------------------------------------------------------------- 1 | name: Integration tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-22.04 8 | env: 9 | SPOTIPY_CLIENT_ID: ${{ secrets.SPOTIPY_CLIENT_ID }} 10 | SPOTIPY_CLIENT_SECRET: ${{ secrets.SPOTIPY_CLIENT_SECRET }} 11 | strategy: 12 | matrix: 13 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install . 24 | - name: Run non user endpoints integration tests 25 | run: | 26 | python -m unittest discover -v tests/integration/non_user_endpoints 27 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | - '**' 7 | tags: 8 | - '*.*.*' 9 | 10 | jobs: 11 | build-n-publish: 12 | name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI 13 | runs-on: ubuntu-22.04 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Set up Python 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: "3.x" 20 | - name: Install pypa/build 21 | run: >- 22 | python -m 23 | pip install 24 | build 25 | --user 26 | - name: Build a binary wheel and a source tarball 27 | run: >- 28 | python -m 29 | build 30 | --sdist 31 | --wheel 32 | --outdir dist/ 33 | . 34 | - name: Publish distribution 📦 to PyPI 35 | if: startsWith(github.ref, 'refs/tags') 36 | uses: pypa/gh-action-pypi-publish@release/v1 37 | with: 38 | password: ${{ secrets.PYPI_API_TOKEN }} 39 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Paul Lamere 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | with open("README.md") as f: 4 | long_description = f.read() 5 | 6 | memcache_cache_reqs = [ 7 | 'pymemcache>=3.5.2' 8 | ] 9 | 10 | extra_reqs = { 11 | 'memcache': [ 12 | 'pymemcache>=3.5.2' 13 | ], 14 | 'test': [ 15 | 'autopep8>=2.3.2', 16 | 'flake8>=7.3.0', 17 | 'flake8-use-fstring>=1.4', 18 | 'isort>=7.0.0' 19 | ] 20 | } 21 | 22 | setup( 23 | name='spotipy', 24 | version='2.25.2', 25 | description='A light weight Python library for the Spotify Web API', 26 | long_description=long_description, 27 | long_description_content_type="text/markdown", 28 | author="@plamere", 29 | author_email="paul@echonest.com", 30 | url='https://spotipy.readthedocs.org/', 31 | project_urls={ 32 | 'Source': 'https://github.com/plamere/spotipy', 33 | }, 34 | python_requires='>3.8', 35 | install_requires=[ 36 | "redis>=3.5.3", # TODO: Move to extras_require in v3 37 | "requests>=2.25.0", 38 | "urllib3>=1.26.0" 39 | ], 40 | extras_require=extra_reqs, 41 | license='MIT', 42 | packages=['spotipy']) 43 | -------------------------------------------------------------------------------- /spotipy/exceptions.py: -------------------------------------------------------------------------------- 1 | class SpotifyBaseException(Exception): 2 | pass 3 | 4 | 5 | class SpotifyException(SpotifyBaseException): 6 | 7 | def __init__(self, http_status, code, msg, reason=None, headers=None): 8 | self.http_status = http_status 9 | self.code = code 10 | self.msg = msg 11 | self.reason = reason 12 | # `headers` is used to support `Retry-After` in the event of a 13 | # 429 status code. 14 | if headers is None: 15 | headers = {} 16 | self.headers = headers 17 | 18 | def __str__(self): 19 | return (f"http status: {self.http_status}, " 20 | f"code: {self.code} - {self.msg}, " 21 | f"reason: {self.reason}") 22 | 23 | 24 | class SpotifyOauthError(SpotifyBaseException): 25 | """ Error during Auth Code or Implicit Grant flow """ 26 | 27 | def __init__(self, message, error=None, error_description=None, *args, **kwargs): 28 | self.error = error 29 | self.error_description = error_description 30 | self.__dict__.update(kwargs) 31 | super().__init__(message, *args, **kwargs) 32 | 33 | 34 | class SpotifyStateError(SpotifyOauthError): 35 | """ The state sent and state received were different """ 36 | 37 | def __init__(self, local_state=None, remote_state=None, message=None, 38 | error=None, error_description=None, *args, **kwargs): 39 | if not message: 40 | message = ("Expected " + local_state + " but received " 41 | + remote_state) 42 | super(SpotifyOauthError, self).__init__(message, error, 43 | error_description, *args, 44 | **kwargs) 45 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | If you would like to contribute to spotipy follow these steps: 4 | 5 | ### Export the needed environment variables 6 | 7 | ```bash 8 | # Linux or Mac 9 | export SPOTIPY_CLIENT_ID=client_id_here 10 | export SPOTIPY_CLIENT_SECRET=client_secret_here 11 | 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/) 12 | export SPOTIPY_REDIRECT_URI=http://127.0.0.1:8080 # Make url is set in app you created to get your ID and SECRET 13 | 14 | # Windows 15 | $env:SPOTIPY_CLIENT_ID="client_id_here" 16 | $env:SPOTIPY_CLIENT_SECRET="client_secret_here" 17 | $env:SPOTIPY_CLIENT_USERNAME="client_username_here" 18 | $env:SPOTIPY_REDIRECT_URI="http://127.0.0.1:8080" 19 | ``` 20 | 21 | ### Branch Overview 22 | 23 | After restarting development on version 3, we decided to restrict commits to certain branches in order to push the development forward. 24 | To give you a flavour of what we mean, here are some examples of what PRs go where: 25 | 26 | **v3**: 27 | 28 | - any kind of refactoring 29 | - better documentation 30 | - enhancements 31 | - code styles 32 | 33 | **master (v2)**: 34 | 35 | - bug fixes 36 | - deprecations 37 | - new endpoints (until we release v3) 38 | - basic functionality 39 | 40 | Just choose v3 if you are unsure which branch to work on. 41 | 42 | ### Create virtual environment, install dependencies, run tests 43 | 44 | ```bash 45 | $ virtualenv --python=python3 env 46 | $ source env/bin/activate 47 | (env) $ pip install -e . 48 | (env) $ python -m unittest discover -v tests 49 | ``` 50 | 51 | ### Lint 52 | 53 | pip install ".[test]" 54 | 55 | To automatically fix some of the code style: 56 | 57 | autopep8 --in-place --aggressive --recursive . 58 | 59 | To verify the code style: 60 | 61 | flake8 . 62 | 63 | To make sure if the import lists are stored correctly: 64 | 65 | isort . -c 66 | 67 | Sort them automatically with: 68 | 69 | isort . 70 | 71 | ### Changelog 72 | 73 | Don't forget to add a short description of your change in the [CHANGELOG](CHANGELOG.md) 74 | 75 | ### Publishing (by maintainer) 76 | 77 | - Bump version in setup.py 78 | - Bump and date changelog 79 | - Add to changelog: 80 | 81 | ## Unreleased 82 | Add your changes below. 83 | 84 | ### Added 85 | 86 | ### Fixed 87 | 88 | ### Removed 89 | 90 | - Commit changes 91 | - Push tag to trigger PyPI build & release workflow 92 | - Create github release with the changelog content 93 | for the version and a short name that describes the main addition 94 | - Verify doc uses latest 95 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | Here at Spotipy, we would like to promote an environment which is open and 6 | welcoming to all. As contributors and maintainers we want to guarantee an 7 | experience which is free of harassment for everyone. By everyone, we mean everyone, 8 | regardless of: age, body size, disability, ethnicity, sex characteristics, gender 9 | identity and expression, level of experience, education, socio-economic status, 10 | nationality, personal appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Here are some examples of conduct which we believe is conducive to and contributes 15 | to a positive environment: 16 | 17 | * Use of welcoming and inclusive language 18 | * Giving due respect to differing viewpoints and experiences 19 | * Being accepting of constructive criticism 20 | * Being focused on what is best for the community 21 | * Displaying empathy towards other members of the community 22 | 23 | Here are some examples of conduct which we believe are unacceptable: 24 | 25 | * Using sexualized language/imagery or giving other community members unwelcome 26 | sexual attention 27 | * Making insulting/derogatory comments to other community members, or making 28 | personal/political attacks against other community members 29 | * Trolling 30 | * Harassing other members publicly or privately 31 | * Doxxing other community members (leaking private information without first getting consent) 32 | * Any other behavior which would be considered inappropriate in a professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | As project maintainers, we are responsible for clearly laying out standards for proper 37 | conduct. We are also responsible for taking the appropriate actions if and when a 38 | community member does not act with proper conduct. An example of appropriate action 39 | is removing/editing/rejecting comments/commits/code/wiki edits/issues or other 40 | contributions made by such an offender. If a community members continues to act in a 41 | way contrary to the Code of Conduct, it is our responsibility to ban them (temporarily 42 | or permanently). 43 | 44 | ## Scope 45 | 46 | Community members are expected to adhere to the Code of Conduct within all project spaces, 47 | as well as in all public spaces when representing the Spotipy community. 48 | 49 | ## Enforcement 50 | Please report instances of abusive, harassing, or otherwise unacceptable behavior to us. 51 | All complaints will be investigated and reviewed by the project team and will result in 52 | an appropriate response. The project team is obligated to maintain confidentiality with 53 | regard to the reporter of an incident. 54 | 55 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may 56 | face temporary or permanent repercussions as determined by other members of the project’s 57 | leadership. 58 | 59 | ## Attribution 60 | 61 | This Code of Conduct is adapted from the Contributor Covenant, version 1.4, available at  62 | https://www.contributor-covenant.org/version/1/4/code-of-conduct.html. For answers to 63 | common questions about this code of conduct, see https://www.contributor-covenant.org/faq 64 | -------------------------------------------------------------------------------- /FAQ.md: -------------------------------------------------------------------------------- 1 | ## Frequently Asked Questions 2 | 3 | ### Is there a way to get this field? 4 | 5 | spotipy can only return fields documented on the Spotify web API https://developer.spotify.com/documentation/web-api/reference/ 6 | 7 | ### How to use spotipy in an API? 8 | 9 | Check out [this example Flask app](https://github.com/spotipy-dev/spotipy-examples/tree/main/apps/flask_api) 10 | 11 | ### How can I store tokens in a database rather than on the filesystem? 12 | 13 | See https://spotipy.readthedocs.io/en/latest/#customized-token-caching 14 | 15 | ### Incorrect user 16 | 17 | Error: 18 | 19 | - You get `You cannot create a playlist for another user` 20 | - You get `You cannot remove tracks from a playlist you don't own` 21 | 22 | Solution: 23 | 24 | - Verify that you are signed in with the correct account on https://spotify.com 25 | - Remove your current token: `rm .cache-{userid}` 26 | - Request a new token by adding `show_dialog=True` to `spotipy.Spotify(auth_manager=SpotifyOAuth(show_dialog=True))` 27 | - Check that `spotipy.me()` shows the correct user id 28 | 29 | ### Why do I get 401 Unauthorized? 30 | 31 | Error: 32 | 33 | spotipy.exceptions.SpotifyException: http status: 401, code:-1 - https://api.spotify.com/v1/ 34 | Unauthorized. 35 | 36 | Solution: 37 | 38 | - You are likely missing a scope when requesting the endpoint, check 39 | https://developer.spotify.com/documentation/web-api/concepts/scopes/ 40 | 41 | ### Search doesn't find some tracks 42 | 43 | Problem: you can see a track on the Spotify app but searching for it using the API doesn't find it. 44 | 45 | Solution: by default `search("abba")` works in the US market. 46 | To search for in your current country, the [country indicator](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2) 47 | must be specified: `search("abba", market="DE")`. 48 | 49 | ### How do I obtain authorization in a headless/browserless environment? 50 | 51 | If you cannot open a browser, set `open_browser=False` when instantiating SpotifyOAuth or SpotifyPKCE. You will be 52 | prompted to open the authorization URI manually. 53 | 54 | See the [headless auth example](https://github.com/spotipy-dev/spotipy-examples/blob/main/scripts/headless.py). 55 | 56 | ### My application is not responding 57 | 58 | This is still speculation, but it seems that Spotify has two limits. A rate limit and a request limit. 59 | 60 | - The rate limit prevents a script from requesting too much from the API in a short period of time. 61 | - The request limit limits how many requests you can make in a 24 hour window. 62 | The limits appear to be endpoint-specific, so each endpoint has its own limits. 63 | 64 | If your application stops responding, it's likely that you've reached the request limit. 65 | 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. 66 | 67 | #### *Why* is the application not responding? 68 | Spotipy (or more precisely `urllib3`) has a backoff-retry strategy built in, which is waiting until the rate limit is gone. 69 | If you want to receive an error instead, then you can pass `retries=0` to `Spotify` like this: 70 | ```python 71 | sp = spotipy.Spotify( 72 | retries=0, 73 | ... 74 | ) 75 | ``` 76 | The error raised is a `spotipy.exceptions.SpotifyException` 77 | 78 | ### I get a 404 when trying to access a Spotify-owned playlist 79 | 80 | Spotify has begun restricting access to algorithmic and Spotify-owned editorial playlists. 81 | Only applications with an existing extended mode will still have access to these playlists. 82 | Read more about this change here: [Introducing some changes to our Web API](https://developer.spotify.com/blog/2024-11-27-changes-to-the-web-api) 83 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Spotipy 2 | 3 | ##### 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. 4 | 5 | ![Integration tests](https://github.com/spotipy-dev/spotipy/actions/workflows/integration_tests.yml/badge.svg?branch=master) [![Documentation Status](https://readthedocs.org/projects/spotipy/badge/?version=master)](https://spotipy.readthedocs.io/en/latest/?badge=master) [![Discord server](https://img.shields.io/discord/1244611850700849183?style=flat&logo=discord&logoColor=7289DA&color=7289DA)](https://discord.gg/HP6xcPsTPJ) 6 | 7 | ## Table of Contents 8 | 9 | - [Features](#features) 10 | - [Installation](#installation) 11 | - [Quick Start](#quick-start) 12 | - [Reporting Issues](#reporting-issues) 13 | - [Contributing](#contributing) 14 | 15 | ## Features 16 | 17 | 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. 18 | 19 | ## Installation 20 | 21 | ```bash 22 | pip install spotipy 23 | ``` 24 | 25 | alternatively, for Windows users 26 | 27 | ```bash 28 | py -m pip install spotipy 29 | ``` 30 | 31 | or upgrade 32 | 33 | ```bash 34 | pip install spotipy --upgrade 35 | ``` 36 | 37 | ## Quick Start 38 | 39 | 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/spotipy-dev/spotipy-examples). 40 | 41 | 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: 42 | 43 | ### Example without user authentication 44 | 45 | ```python 46 | import spotipy 47 | from spotipy.oauth2 import SpotifyClientCredentials 48 | 49 | sp = spotipy.Spotify(auth_manager=SpotifyClientCredentials(client_id="YOUR_APP_CLIENT_ID", 50 | client_secret="YOUR_APP_CLIENT_SECRET")) 51 | 52 | results = sp.search(q='weezer', limit=20) 53 | for idx, track in enumerate(results['tracks']['items']): 54 | print(idx, track['name']) 55 | ``` 56 | Expected result: 57 | ``` 58 | 0 Island In The Sun 59 | 1 Say It Ain't So 60 | 2 Buddy Holly 61 | . 62 | . 63 | . 64 | 18 Troublemaker 65 | 19 Feels Like Summer 66 | ``` 67 | 68 | 69 | ### Example with user authentication 70 | 71 | A redirect URI must be added to your application at [My Dashboard](https://developer.spotify.com/dashboard/applications) to access user authenticated features. 72 | 73 | ```python 74 | import spotipy 75 | from spotipy.oauth2 import SpotifyOAuth 76 | 77 | sp = spotipy.Spotify(auth_manager=SpotifyOAuth(client_id="YOUR_APP_CLIENT_ID", 78 | client_secret="YOUR_APP_CLIENT_SECRET", 79 | redirect_uri="YOUR_APP_REDIRECT_URI", 80 | scope="user-library-read")) 81 | 82 | results = sp.current_user_saved_tracks() 83 | for idx, item in enumerate(results['items']): 84 | track = item['track'] 85 | print(idx, track['artists'][0]['name'], " – ", track['name']) 86 | ``` 87 | Expected result will be the list of music that you liked. For example if you liked Red and Sunflower, the result will be: 88 | ``` 89 | 0 Post Malone – Sunflower - Spider-Man: Into the Spider-Verse 90 | 1 Taylor Swift – Red 91 | ``` 92 | 93 | 94 | ## Reporting Issues 95 | 96 | For common questions please check our [FAQ](FAQ.md). 97 | 98 | You can ask questions about Spotipy on 99 | [Stack Overflow](http://stackoverflow.com/questions/ask). 100 | Don’t forget to add the *Spotipy* tag, and any other relevant tags as well, before posting. 101 | 102 | If you have suggestions, bugs or other issues specific to this library, 103 | file them [here](https://github.com/plamere/spotipy/issues). 104 | Or just send a pull request. 105 | 106 | ## Contributing 107 | 108 | 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 109 | 110 | > #### [Visit the guideline](https://spotipy.readthedocs.io/en/#contribute) 111 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. linkcheck to check all external links for integrity 37 | echo. doctest to run all doctests embedded in the documentation if enabled 38 | goto end 39 | ) 40 | 41 | if "%1" == "clean" ( 42 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 43 | del /q /s %BUILDDIR%\* 44 | goto end 45 | ) 46 | 47 | if "%1" == "html" ( 48 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 49 | if errorlevel 1 exit /b 1 50 | echo. 51 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 52 | goto end 53 | ) 54 | 55 | if "%1" == "dirhtml" ( 56 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 57 | if errorlevel 1 exit /b 1 58 | echo. 59 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 60 | goto end 61 | ) 62 | 63 | if "%1" == "singlehtml" ( 64 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 68 | goto end 69 | ) 70 | 71 | if "%1" == "pickle" ( 72 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished; now you can process the pickle files. 76 | goto end 77 | ) 78 | 79 | if "%1" == "json" ( 80 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished; now you can process the JSON files. 84 | goto end 85 | ) 86 | 87 | if "%1" == "htmlhelp" ( 88 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can run HTML Help Workshop with the ^ 92 | .hhp project file in %BUILDDIR%/htmlhelp. 93 | goto end 94 | ) 95 | 96 | if "%1" == "qthelp" ( 97 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 98 | if errorlevel 1 exit /b 1 99 | echo. 100 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 101 | .qhcp project file in %BUILDDIR%/qthelp, like this: 102 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\spotipy.qhcp 103 | echo.To view the help file: 104 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\spotipy.ghc 105 | goto end 106 | ) 107 | 108 | if "%1" == "devhelp" ( 109 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 110 | if errorlevel 1 exit /b 1 111 | echo. 112 | echo.Build finished. 113 | goto end 114 | ) 115 | 116 | if "%1" == "epub" ( 117 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 118 | if errorlevel 1 exit /b 1 119 | echo. 120 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 121 | goto end 122 | ) 123 | 124 | if "%1" == "latex" ( 125 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 129 | goto end 130 | ) 131 | 132 | if "%1" == "text" ( 133 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The text files are in %BUILDDIR%/text. 137 | goto end 138 | ) 139 | 140 | if "%1" == "man" ( 141 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 145 | goto end 146 | ) 147 | 148 | if "%1" == "texinfo" ( 149 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 150 | if errorlevel 1 exit /b 1 151 | echo. 152 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 153 | goto end 154 | ) 155 | 156 | if "%1" == "gettext" ( 157 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 158 | if errorlevel 1 exit /b 1 159 | echo. 160 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 161 | goto end 162 | ) 163 | 164 | if "%1" == "changes" ( 165 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 166 | if errorlevel 1 exit /b 1 167 | echo. 168 | echo.The overview file is in %BUILDDIR%/changes. 169 | goto end 170 | ) 171 | 172 | if "%1" == "linkcheck" ( 173 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 174 | if errorlevel 1 exit /b 1 175 | echo. 176 | echo.Link check complete; look for any errors in the above output ^ 177 | or in %BUILDDIR%/linkcheck/output.txt. 178 | goto end 179 | ) 180 | 181 | if "%1" == "doctest" ( 182 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 183 | if errorlevel 1 exit /b 1 184 | echo. 185 | echo.Testing of doctests in the sources finished, look at the ^ 186 | results in %BUILDDIR%/doctest/output.txt. 187 | goto end 188 | ) 189 | 190 | :end 191 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 19 | help: 20 | @echo "Please use \`make ' where is one of" 21 | @echo " html to make standalone HTML files" 22 | @echo " dirhtml to make HTML files named index.html in directories" 23 | @echo " singlehtml to make a single large HTML file" 24 | @echo " pickle to make pickle files" 25 | @echo " json to make JSON files" 26 | @echo " htmlhelp to make HTML files and a HTML help project" 27 | @echo " qthelp to make HTML files and a qthelp project" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 31 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | clean: 42 | -rm -rf $(BUILDDIR)/* 43 | 44 | html: 45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 48 | 49 | dirhtml: 50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 51 | @echo 52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 53 | 54 | singlehtml: 55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 56 | @echo 57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 58 | 59 | pickle: 60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 61 | @echo 62 | @echo "Build finished; now you can process the pickle files." 63 | 64 | json: 65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 66 | @echo 67 | @echo "Build finished; now you can process the JSON files." 68 | 69 | htmlhelp: 70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 71 | @echo 72 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 73 | ".hhp project file in $(BUILDDIR)/htmlhelp." 74 | 75 | qthelp: 76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 77 | @echo 78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/spotipy.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/spotipy.qhc" 83 | 84 | devhelp: 85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 86 | @echo 87 | @echo "Build finished." 88 | @echo "To view the help file:" 89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/spotipy" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/spotipy" 91 | @echo "# devhelp" 92 | 93 | epub: 94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 95 | @echo 96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 97 | 98 | latex: 99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 100 | @echo 101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 103 | "(use \`make latexpdf' here to do that automatically)." 104 | 105 | latexpdf: 106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 107 | @echo "Running LaTeX files through pdflatex..." 108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 110 | 111 | text: 112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 113 | @echo 114 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 115 | 116 | man: 117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 118 | @echo 119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 120 | 121 | texinfo: 122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 123 | @echo 124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 125 | @echo "Run \`make' in that directory to run these through makeinfo" \ 126 | "(use \`make info' here to do that automatically)." 127 | 128 | info: 129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 130 | @echo "Running Texinfo files through makeinfo..." 131 | make -C $(BUILDDIR)/texinfo info 132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 133 | 134 | gettext: 135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 136 | @echo 137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 138 | 139 | changes: 140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 141 | @echo 142 | @echo "The overview file is in $(BUILDDIR)/changes." 143 | 144 | linkcheck: 145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 146 | @echo 147 | @echo "Link check complete; look for any errors in the above output " \ 148 | "or in $(BUILDDIR)/linkcheck/output.txt." 149 | 150 | doctest: 151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 152 | @echo "Testing of doctests in the sources finished, look at the " \ 153 | "results in $(BUILDDIR)/doctest/output.txt." 154 | -------------------------------------------------------------------------------- /spotipy/util.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | """ Shows a user's playlists. This needs to be authenticated via OAuth. """ 4 | 5 | __all__ = ["CLIENT_CREDS_ENV_VARS", "prompt_for_user_token"] 6 | 7 | import logging 8 | import os 9 | import warnings 10 | from types import TracebackType 11 | 12 | import requests 13 | import urllib3 14 | 15 | import spotipy 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | CLIENT_CREDS_ENV_VARS = { 20 | "client_id": "SPOTIPY_CLIENT_ID", 21 | "client_secret": "SPOTIPY_CLIENT_SECRET", 22 | "client_username": "SPOTIPY_CLIENT_USERNAME", 23 | "redirect_uri": "SPOTIPY_REDIRECT_URI", 24 | } 25 | 26 | # workaround for garbage collection 27 | REQUESTS_SESSION = requests.Session 28 | 29 | 30 | def prompt_for_user_token( 31 | username=None, 32 | scope=None, 33 | client_id=None, 34 | client_secret=None, 35 | redirect_uri=None, 36 | cache_path=None, 37 | oauth_manager=None, 38 | show_dialog=False 39 | ): 40 | """ Prompt the user to login if necessary and returns a user token 41 | suitable for use with the spotipy.Spotify constructor. 42 | 43 | .. deprecated:: 44 | This method is deprecated and may be removed in a future version. 45 | 46 | Parameters: 47 | - username - the Spotify username. (optional) 48 | - scope - the desired scope of the request. (optional) 49 | - client_id - the client ID of your app. (required) 50 | - client_secret - the client secret of your app. (required) 51 | - redirect_uri - the redirect URI of your app. (required) 52 | - cache_path - path to location to save tokens. (required) 53 | - oauth_manager - OAuth manager object. (optional) 54 | - show_dialog - If True, a login prompt always shows or defaults to False. (optional) 55 | """ 56 | warnings.warn( 57 | "'prompt_for_user_token' is deprecated." 58 | "Use the following instead: " 59 | " auth_manager=SpotifyOAuth(scope=scope)" 60 | " spotipy.Spotify(auth_manager=auth_manager)", 61 | DeprecationWarning 62 | ) 63 | 64 | if not oauth_manager: 65 | if not client_id: 66 | client_id = os.getenv("SPOTIPY_CLIENT_ID") 67 | 68 | if not client_secret: 69 | client_secret = os.getenv("SPOTIPY_CLIENT_SECRET") 70 | 71 | if not redirect_uri: 72 | redirect_uri = os.getenv("SPOTIPY_REDIRECT_URI") 73 | 74 | if not client_id: 75 | logger.warning( 76 | """ 77 | You need to set your Spotify API credentials. 78 | You can do this by setting environment variables like so: 79 | 80 | export SPOTIPY_CLIENT_ID='your-spotify-client-id' 81 | export SPOTIPY_CLIENT_SECRET='your-spotify-client-secret' 82 | export SPOTIPY_REDIRECT_URI='your-app-redirect-url' 83 | 84 | Get your credentials at 85 | https://developer.spotify.com/my-applications 86 | """ 87 | ) 88 | raise spotipy.SpotifyException(550, -1, "no credentials set") 89 | 90 | sp_oauth = oauth_manager or spotipy.SpotifyOAuth( 91 | client_id, 92 | client_secret, 93 | redirect_uri, 94 | scope=scope, 95 | cache_path=cache_path, 96 | username=username, 97 | show_dialog=show_dialog 98 | ) 99 | 100 | # try to get a valid token for this user, from the cache, 101 | # if not in the cache, then create a new (this will send 102 | # the user to a web page where they can authorize this app) 103 | 104 | token_info = sp_oauth.validate_token(sp_oauth.cache_handler.get_cached_token()) 105 | 106 | if not token_info: 107 | code = sp_oauth.get_auth_response() 108 | token = sp_oauth.get_access_token(code, as_dict=False) 109 | else: 110 | return token_info["access_token"] 111 | 112 | # Auth'ed API request 113 | if token: 114 | return token 115 | else: 116 | return None 117 | 118 | 119 | def get_host_port(netloc): 120 | """ Split the network location string into host and port and returns a tuple 121 | where the host is a string and the the port is an integer. 122 | 123 | Parameters: 124 | - netloc - a string representing the network location. 125 | """ 126 | if ":" in netloc: 127 | host, port = netloc.split(":", 1) 128 | port = int(port) 129 | else: 130 | host = netloc 131 | port = None 132 | 133 | return host, port 134 | 135 | 136 | def normalize_scope(scope): 137 | """Normalize the scope to verify that it is a list or tuple. A string 138 | input will split the string by commas to create a list of scopes. 139 | A list or tuple input is used directly. 140 | 141 | Parameters: 142 | - scope - a string representing scopes separated by commas, 143 | or a list/tuple of scopes. 144 | """ 145 | if scope: 146 | if isinstance(scope, str): 147 | scopes = scope.split(',') 148 | elif isinstance(scope, list) or isinstance(scope, tuple): 149 | scopes = scope 150 | else: 151 | raise Exception( 152 | "Unsupported scope value, please either provide a list of scopes, " 153 | "or a string of scopes separated by commas." 154 | ) 155 | return " ".join(sorted(scopes)) 156 | else: 157 | return None 158 | 159 | 160 | class Retry(urllib3.Retry): 161 | """ 162 | Custom class for printing a warning when a rate/request limit is reached. 163 | """ 164 | 165 | def increment( 166 | self, 167 | method: str | None = None, 168 | url: str | None = None, 169 | response: urllib3.BaseHTTPResponse | None = None, 170 | error: Exception | None = None, 171 | _pool: urllib3.connectionpool.ConnectionPool | None = None, 172 | _stacktrace: TracebackType | None = None, 173 | ) -> urllib3.Retry: 174 | if response: 175 | retry_header = response.headers.get("Retry-After") 176 | if self.is_retry(method, response.status, bool(retry_header)): 177 | retry_header = retry_header or 0 178 | logger.warning("Your application has reached a rate/request limit. " 179 | f"Retry will occur after: {retry_header} s") 180 | return super().increment(method, 181 | url, 182 | response=response, 183 | error=error, 184 | _pool=_pool, 185 | _stacktrace=_stacktrace) 186 | -------------------------------------------------------------------------------- /TUTORIAL.md: -------------------------------------------------------------------------------- 1 | # Spotipy Tutorial for Beginners 2 | Hello and welcome to the Spotipy Tutorial for Beginners. If you have limited experience coding in Python and have never used Spotipy or the Spotify API before, you've come to the right place. This tutorial will walk you through all the steps necessary to set up Spotipy and use it to accomplish a simple task. 3 | 4 | ## Prerequisites 5 | In order to complete this tutorial successfully, there are a few things that you should already have installed: 6 | 7 | **1. python3** 8 | 9 | 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 10 | If you see a version number, Python is already installed. If not, you can download it here: https://www.python.org/downloads/ 11 | 12 | **2. pip package manager** 13 | 14 | You can check to see if you have pip installed by opening up Terminal and typing the following command: pip --version 15 | 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/ 16 | 17 | A. After ensuring that pip is installed, run the following command in Terminal to install Spotipy: pip install spotipy --upgrade 18 | 19 | **3. Experience with Basic Linux Commands** 20 | 21 | 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. 22 | 23 | Once those three setup items are taken care of, you're ready to start learning how to use Spotipy! 24 | 25 | ## Step 1. Creating a Spotify Account 26 | Spotipy relies on the Spotify API. In order to use the Spotify API, you'll need to create a Spotify developer account. 27 | 28 | 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. 29 | 30 | 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://127.0.0.1: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. 31 | 32 | 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. 33 | 34 | ## Step 2. Installation and Setup 35 | 36 | 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``` 37 | 38 | 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 39 | 40 | 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 41 | 42 | D. Paste the following code into your main.py file: 43 | ``` 44 | import spotipy 45 | from spotipy.oauth2 import SpotifyOAuth 46 | 47 | sp = spotipy.Spotify(auth_manager=SpotifyOAuth(client_id="YOUR_APP_CLIENT_ID", 48 | client_secret="YOUR_APP_CLIENT_SECRET", 49 | redirect_uri="YOUR_APP_REDIRECT_URI", 50 | scope="user-library-read")) 51 | ``` 52 | 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. 53 | 54 | ## Step 3. Start Using Spotipy 55 | 56 | 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. 57 | 58 | For now, let's assume that we want to print the names of all the albums on Spotify by Taylor Swift: 59 | 60 | 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 61 | 62 | B. Add the URI as a variable in main.py. Notice the prefix added the URI: 63 | ``` 64 | taylor_uri = 'spotify:artist:06HL4z0CvFAxyc27GXpf02' 65 | ``` 66 | C. Add the following code that will get all of Taylor's album names from Spotify and iterate through them to print them all to standard output. 67 | ``` 68 | results = sp.artist_albums(taylor_uri, album_type='album') 69 | albums = results['items'] 70 | while results['next']: 71 | results = sp.next(results) 72 | albums.extend(results['items']) 73 | 74 | for album in albums: 75 | print(album['name']) 76 | ``` 77 | 78 | D. Close main.py and return to the directory that contains main.py. You can then run your app by entering the following command: python main.py 79 | 80 | 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. 81 | 82 | F. Return to your terminal - you should see all of Taylor's albums printed out there. 83 | 84 | ## Troubleshooting Tips 85 | A. Command not found running the application "zsh: command not found: python" 86 | 87 | Check which Python version that you have by running the command: 88 | ```python --version ``` or ```python3 --version```. 89 | 90 | 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: 91 | ``` python3 main.py``` 92 | 93 | B. Encountering package error: 94 | 95 | If you are seeing an error "ModuleNotFoundError: No module named 'spotipy'", this means you have not installed the package. 96 | Run the command: 97 | ``` 98 | pip install spotipy 99 | ``` 100 | After the package is installed, run the app again. 101 | -------------------------------------------------------------------------------- /spotipy/cache_handler.py: -------------------------------------------------------------------------------- 1 | __all__ = [ 2 | 'CacheHandler', 3 | 'CacheFileHandler', 4 | 'DjangoSessionCacheHandler', 5 | 'FlaskSessionCacheHandler', 6 | 'MemoryCacheHandler', 7 | 'RedisCacheHandler', 8 | 'MemcacheCacheHandler'] 9 | 10 | import errno 11 | import json 12 | import logging 13 | import os 14 | 15 | from redis import RedisError 16 | 17 | from spotipy.util import CLIENT_CREDS_ENV_VARS 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | class CacheHandler(): 23 | """ 24 | An abstraction layer for handling the caching and retrieval of 25 | authorization tokens. 26 | 27 | Custom extensions of this class must implement get_cached_token 28 | and save_token_to_cache methods with the same input and output 29 | structure as the CacheHandler class. 30 | """ 31 | 32 | def get_cached_token(self): 33 | """ 34 | Get and return a token_info dictionary object. 35 | """ 36 | # return token_info 37 | raise NotImplementedError() 38 | 39 | def save_token_to_cache(self, token_info): 40 | """ 41 | Save a token_info dictionary object to the cache and return None. 42 | """ 43 | raise NotImplementedError() 44 | 45 | 46 | class CacheFileHandler(CacheHandler): 47 | """ 48 | Handles reading and writing cached Spotify authorization tokens 49 | as json files on disk. 50 | """ 51 | 52 | def __init__(self, 53 | cache_path=None, 54 | username=None, 55 | encoder_cls=None): 56 | """ 57 | Parameters: 58 | * cache_path: May be supplied, will otherwise be generated 59 | (takes precedence over `username`) 60 | * username: May be supplied or set as environment variable 61 | (will set `cache_path` to `.cache-{username}`) 62 | * encoder_cls: May be supplied as a means of overwriting the 63 | default serializer used for writing tokens to disk 64 | """ 65 | self.encoder_cls = encoder_cls 66 | if cache_path: 67 | self.cache_path = cache_path 68 | else: 69 | cache_path = ".cache" 70 | username = (username or os.getenv(CLIENT_CREDS_ENV_VARS["client_username"])) 71 | if username: 72 | cache_path += "-" + str(username) 73 | self.cache_path = cache_path 74 | 75 | def get_cached_token(self): 76 | token_info = None 77 | 78 | try: 79 | with open(self.cache_path, encoding='utf-8') as f: 80 | token_info_string = f.read() 81 | token_info = json.loads(token_info_string) 82 | 83 | except OSError as error: 84 | if error.errno == errno.ENOENT: 85 | logger.debug(f"cache does not exist at: {self.cache_path}") 86 | else: 87 | logger.warning(f"Couldn't read cache at: {self.cache_path}") 88 | except json.JSONDecodeError: 89 | logger.warning(f"Couldn't decode JSON from cache at: {self.cache_path}") 90 | 91 | return token_info 92 | 93 | def save_token_to_cache(self, token_info): 94 | try: 95 | with open(self.cache_path, "w", encoding='utf-8') as f: 96 | f.write(json.dumps(token_info, cls=self.encoder_cls)) 97 | # https://github.com/spotipy-dev/spotipy/security/advisories/GHSA-pwhh-q4h6-w599 98 | os.chmod(self.cache_path, 0o600) 99 | except OSError: 100 | logger.warning(f"Couldn't write token to cache at: {self.cache_path}") 101 | except FileNotFoundError: 102 | logger.warning(f"Couldn't set permissions to cache file at: {self.cache_path}") 103 | 104 | 105 | class MemoryCacheHandler(CacheHandler): 106 | """ 107 | A cache handler that simply stores the token info in memory as an 108 | instance attribute of this class. The token info will be lost when this 109 | instance is freed. 110 | """ 111 | 112 | def __init__(self, token_info=None): 113 | """ 114 | Parameters: 115 | * token_info: The token info to store in memory. Can be None. 116 | """ 117 | self.token_info = token_info 118 | 119 | def get_cached_token(self): 120 | return self.token_info 121 | 122 | def save_token_to_cache(self, token_info): 123 | self.token_info = token_info 124 | 125 | 126 | class DjangoSessionCacheHandler(CacheHandler): 127 | """ 128 | A cache handler that stores the token info in the session framework 129 | provided by Django. 130 | 131 | Read more at https://docs.djangoproject.com/en/3.2/topics/http/sessions/ 132 | """ 133 | 134 | def __init__(self, request): 135 | """ 136 | Parameters: 137 | * request: HttpRequest object provided by Django for every 138 | incoming request 139 | """ 140 | self.request = request 141 | 142 | def get_cached_token(self): 143 | token_info = None 144 | try: 145 | token_info = self.request.session['token_info'] 146 | except KeyError: 147 | logger.debug("Token not found in the session") 148 | 149 | return token_info 150 | 151 | def save_token_to_cache(self, token_info): 152 | try: 153 | self.request.session['token_info'] = token_info 154 | except Exception as e: 155 | logger.warning(f"Error saving token to cache: {e}") 156 | 157 | 158 | class FlaskSessionCacheHandler(CacheHandler): 159 | """ 160 | A cache handler that stores the token info in the session framework 161 | provided by flask. 162 | """ 163 | 164 | def __init__(self, session): 165 | self.session = session 166 | 167 | def get_cached_token(self): 168 | token_info = None 169 | try: 170 | token_info = self.session["token_info"] 171 | except KeyError: 172 | logger.debug("Token not found in the session") 173 | 174 | return token_info 175 | 176 | def save_token_to_cache(self, token_info): 177 | try: 178 | self.session["token_info"] = token_info 179 | except Exception as e: 180 | logger.warning(f"Error saving token to cache: {e}") 181 | 182 | 183 | class RedisCacheHandler(CacheHandler): 184 | """ 185 | A cache handler that stores the token info in the Redis. 186 | """ 187 | 188 | def __init__(self, redis, key=None): 189 | """ 190 | Parameters: 191 | * redis: Redis object provided by redis-py library 192 | (https://github.com/redis/redis-py) 193 | * key: May be supplied, will otherwise be generated 194 | (takes precedence over `token_info`) 195 | """ 196 | self.redis = redis 197 | self.key = key if key else 'token_info' 198 | 199 | def get_cached_token(self): 200 | token_info = None 201 | try: 202 | token_info = self.redis.get(self.key) 203 | if token_info: 204 | return json.loads(token_info) 205 | except RedisError as e: 206 | logger.warning(f"Error getting token from cache: {e}") 207 | 208 | return token_info 209 | 210 | def save_token_to_cache(self, token_info): 211 | try: 212 | self.redis.set(self.key, json.dumps(token_info)) 213 | except RedisError as e: 214 | logger.warning(f"Error saving token to cache: {e}") 215 | 216 | 217 | class MemcacheCacheHandler(CacheHandler): 218 | """A Cache handler that stores the token info in Memcache using the pymemcache client 219 | """ 220 | 221 | def __init__(self, memcache, key=None) -> None: 222 | """ 223 | Parameters: 224 | * memcache: memcache client object provided by pymemcache 225 | (https://pymemcache.readthedocs.io/en/latest/getting_started.html) 226 | * key: May be supplied, will otherwise be generated 227 | (takes precedence over `token_info`) 228 | """ 229 | self.memcache = memcache 230 | self.key = key if key else 'token_info' 231 | 232 | def get_cached_token(self): 233 | from pymemcache import MemcacheError 234 | try: 235 | token_info = self.memcache.get(self.key) 236 | if token_info: 237 | return json.loads(token_info.decode()) 238 | except MemcacheError as e: 239 | logger.warning(f"Error getting token to cache: {e}") 240 | 241 | def save_token_to_cache(self, token_info): 242 | from pymemcache import MemcacheError 243 | try: 244 | self.memcache.set(self.key, json.dumps(token_info)) 245 | except MemcacheError as e: 246 | logger.warning(f"Error saving token to cache: {e}") 247 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # 2 | # spotipy documentation build configuration file, created by 3 | # sphinx-quickstart on Thu Aug 21 11:04:39 2014. 4 | # 5 | # This file is execfile()d with the current directory set to its containing dir. 6 | # 7 | # Note that not all possible configuration values are present in this 8 | # autogenerated file. 9 | # 10 | # All configuration values have a default; values that are commented out 11 | # serve to show the default. 12 | 13 | import os 14 | import sys 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | # sys.path.insert(0, os.path.abspath('.')) 20 | sys.path.insert(0, os.path.abspath('.')) 21 | sys.path.insert(0, os.path.abspath("..")) 22 | 23 | import spotipy 24 | 25 | # -- General configuration ----------------------------------------------------- 26 | 27 | # If your documentation needs a minimal Sphinx version, state it here. 28 | # needs_sphinx = '1.0' 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be extensions 31 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 32 | extensions = [ 33 | 'sphinx.ext.autodoc', 34 | 'sphinx_rtd_theme' 35 | ] 36 | 37 | # Add any paths that contain templates here, relative to this directory. 38 | templates_path = ['_templates'] 39 | 40 | # The suffix of source filenames. 41 | source_suffix = '.rst' 42 | 43 | # The encoding of source files. 44 | # source_encoding = 'utf-8-sig' 45 | 46 | # The master toctree document. 47 | master_doc = 'index' 48 | 49 | # General information about the project. 50 | project = 'spotipy' 51 | copyright = '2014, Paul Lamere' 52 | 53 | # The version info for the project you're documenting, acts as replacement for 54 | # |version| and |release|, also used in various other places throughout the 55 | # built documents. 56 | # 57 | # The short X.Y version. 58 | version = '2.0' 59 | # The full version, including alpha/beta/rc tags. 60 | release = '2.0' 61 | 62 | # The language for content autogenerated by Sphinx. Refer to documentation 63 | # for a list of supported languages. 64 | # language = None 65 | 66 | # There are two options for replacing |today|: either, you set today to some 67 | # non-false value, then it is used: 68 | # today = '' 69 | # Else, today_fmt is used as the format for a strftime call. 70 | # today_fmt = '%B %d, %Y' 71 | 72 | # List of patterns, relative to source directory, that match files and 73 | # directories to ignore when looking for source files. 74 | exclude_patterns = ['_build'] 75 | 76 | # The reST default role (used for this markup: `text`) to use for all documents. 77 | # default_role = None 78 | 79 | # If true, '()' will be appended to :func: etc. cross-reference text. 80 | # add_function_parentheses = True 81 | 82 | # If true, the current module name will be prepended to all description 83 | # unit titles (such as .. function::). 84 | # add_module_names = True 85 | 86 | # If true, sectionauthor and moduleauthor directives will be shown in the 87 | # output. They are ignored by default. 88 | # show_authors = False 89 | 90 | # The name of the Pygments (syntax highlighting) style to use. 91 | pygments_style = 'sphinx' 92 | 93 | # A list of ignored prefixes for module index sorting. 94 | # modindex_common_prefix = [] 95 | 96 | 97 | # -- Options for HTML output --------------------------------------------------- 98 | 99 | # The theme to use for HTML and HTML Help pages. See the documentation for 100 | # a list of builtin themes. 101 | html_theme = 'sphinx_rtd_theme' 102 | 103 | # Theme options are theme-specific and customize the look and feel of a theme 104 | # further. For a list of options available for each theme, see the 105 | # documentation. 106 | # html_theme_options = {} 107 | 108 | # Add any paths that contain custom themes here, relative to this directory. 109 | # html_theme_path = [] 110 | 111 | # The name for this set of Sphinx documents. If None, it defaults to 112 | # " v documentation". 113 | # html_title = None 114 | 115 | # A shorter title for the navigation bar. Default is the same as html_title. 116 | # html_short_title = None 117 | 118 | # The name of an image file (relative to this directory) to place at the top 119 | # of the sidebar. 120 | # html_logo = None 121 | 122 | # The name of an image file (within the static path) to use as favicon of the 123 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 124 | # pixels large. 125 | # html_favicon = None 126 | 127 | # Add any paths that contain custom static files (such as style sheets) here, 128 | # relative to this directory. They are copied after the builtin static files, 129 | # so a file named "default.css" will overwrite the builtin "default.css". 130 | html_static_path = ['_static'] 131 | 132 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 133 | # using the given strftime format. 134 | # html_last_updated_fmt = '%b %d, %Y' 135 | 136 | # If true, SmartyPants will be used to convert quotes and dashes to 137 | # typographically correct entities. 138 | # html_use_smartypants = True 139 | 140 | # Custom sidebar templates, maps document names to template names. 141 | # html_sidebars = {} 142 | 143 | # Additional templates that should be rendered to pages, maps page names to 144 | # template names. 145 | # html_additional_pages = {} 146 | 147 | # If false, no module index is generated. 148 | # html_domain_indices = True 149 | 150 | # If false, no index is generated. 151 | # html_use_index = True 152 | 153 | # If true, the index is split into individual pages for each letter. 154 | # html_split_index = False 155 | 156 | # If true, links to the reST sources are added to the pages. 157 | # html_show_sourcelink = True 158 | 159 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 160 | # html_show_sphinx = True 161 | 162 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 163 | # html_show_copyright = True 164 | 165 | # If true, an OpenSearch description file will be output, and all pages will 166 | # contain a tag referring to it. The value of this option must be the 167 | # base URL from which the finished HTML is served. 168 | # html_use_opensearch = '' 169 | 170 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 171 | # html_file_suffix = None 172 | 173 | # Output file base name for HTML help builder. 174 | htmlhelp_basename = 'spotipydoc' 175 | 176 | 177 | # -- Options for LaTeX output -------------------------------------------------- 178 | 179 | latex_elements = { 180 | # The paper size ('letterpaper' or 'a4paper'). 181 | # 'papersize': 'letterpaper', 182 | 183 | # The font size ('10pt', '11pt' or '12pt'). 184 | # 'pointsize': '10pt', 185 | 186 | # Additional stuff for the LaTeX preamble. 187 | # 'preamble': '', 188 | } 189 | 190 | # Grouping the document tree into LaTeX files. List of tuples 191 | # (source start file, target name, title, author, documentclass [howto/manual]). 192 | latex_documents = [ 193 | ('index', 'spotipy.tex', 'spotipy Documentation', 194 | 'Paul Lamere', 'manual'), 195 | ] 196 | 197 | # The name of an image file (relative to this directory) to place at the top of 198 | # the title page. 199 | # latex_logo = None 200 | 201 | # For "manual" documents, if this is true, then toplevel headings are parts, 202 | # not chapters. 203 | # latex_use_parts = False 204 | 205 | # If true, show page references after internal links. 206 | # latex_show_pagerefs = False 207 | 208 | # If true, show URL addresses after external links. 209 | # latex_show_urls = False 210 | 211 | # Documents to append as an appendix to all manuals. 212 | # latex_appendices = [] 213 | 214 | # If false, no module index is generated. 215 | # latex_domain_indices = True 216 | 217 | 218 | # -- Options for manual page output -------------------------------------------- 219 | 220 | # One entry per manual page. List of tuples 221 | # (source start file, name, description, authors, manual section). 222 | man_pages = [ 223 | ('index', 'spotipy', 'spotipy Documentation', 224 | ['Paul Lamere'], 1) 225 | ] 226 | 227 | # If true, show URL addresses after external links. 228 | # man_show_urls = False 229 | 230 | 231 | # -- Options for Texinfo output ------------------------------------------------ 232 | 233 | # Grouping the document tree into Texinfo files. List of tuples 234 | # (source start file, target name, title, author, 235 | # dir menu entry, description, category) 236 | texinfo_documents = [ 237 | ('index', 'spotipy', 'spotipy Documentation', 238 | 'Paul Lamere', 'spotipy', 'One line description of project.', 239 | 'Miscellaneous'), 240 | ] 241 | 242 | # Documents to append as an appendix to all manuals. 243 | # texinfo_appendices = [] 244 | 245 | # If false, no module index is generated. 246 | # texinfo_domain_indices = True 247 | 248 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 249 | # texinfo_show_urls = 'footnote' 250 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. image:: images/spotify-web-api-doc.jpg 2 | :width: 100 % 3 | 4 | Welcome to Spotipy! 5 | =================================== 6 | 7 | *Spotipy* is a lightweight Python library for the `Spotify Web API 8 | `_. With *Spotipy* 9 | you get full access to all of the music data provided by the Spotify platform. 10 | 11 | Features 12 | ======== 13 | 14 | *Spotipy* supports all of the features of the Spotify Web API including access 15 | to all end points, and support for user authorization. For details on the 16 | capabilities you are encouraged to review the `Spotify Web 17 | API `_ documentation. 18 | 19 | Installation 20 | ============ 21 | 22 | Install or upgrade *Spotipy* with:: 23 | 24 | pip install spotipy --upgrade 25 | 26 | You can also obtain the source code from the `Spotipy GitHub repository `_. 27 | 28 | 29 | Getting Started 30 | =============== 31 | 32 | All methods require user authorization. You will need to register your app at 33 | `My Dashboard `_ 34 | to get the credentials necessary to make authorized calls 35 | (a *client id* and *client secret*). 36 | 37 | 38 | 39 | *Spotipy* supports two authorization flows: 40 | 41 | - **Authorization Code flow** This method is suitable for long-running applications 42 | which the user logs into once. It provides an access token that can be refreshed. 43 | 44 | .. note:: Requires you to add a redirect URI to your application at 45 | `My Dashboard `_. 46 | See `Redirect URI`_ for more details. 47 | 48 | - **Client Credentials flow** This method makes it possible 49 | to authenticate your requests to the Spotify Web API and to obtain 50 | a higher rate limit than you would with the Authorization Code flow. 51 | 52 | 53 | For guidance on setting your app credentials watch this `video tutorial `_ or follow the 54 | `Spotipy Tutorial for Beginners `_. 55 | 56 | For a longer tutorial with examples included, refer to this `video playlist `_. 57 | 58 | 59 | Authorization Code Flow 60 | ======================= 61 | 62 | This flow is suitable for long-running applications in which the user grants 63 | permission only once. It provides an access token that can be refreshed. 64 | Since the token exchange involves sending your secret key, perform this on a 65 | secure location, like a backend service, and not from a client such as a 66 | browser or from a mobile app. 67 | 68 | Quick start 69 | ----------- 70 | 71 | To support the **Client Authorization Code Flow** *Spotipy* provides a 72 | class SpotifyOAuth that can be used to authenticate requests like so:: 73 | 74 | import spotipy 75 | from spotipy.oauth2 import SpotifyOAuth 76 | 77 | scope = "user-library-read" 78 | 79 | sp = spotipy.Spotify(auth_manager=SpotifyOAuth(scope=scope)) 80 | 81 | results = sp.current_user_saved_tracks() 82 | for idx, item in enumerate(results['items']): 83 | track = item['track'] 84 | print(idx, track['artists'][0]['name'], " – ", track['name']) 85 | 86 | or if you are reluctant to immortalize your app credentials in your source code, 87 | you can set environment variables like so (use ``$env:"credentials"`` instead of ``export`` 88 | on Windows):: 89 | 90 | export SPOTIPY_CLIENT_ID='your-spotify-client-id' 91 | export SPOTIPY_CLIENT_SECRET='your-spotify-client-secret' 92 | export SPOTIPY_REDIRECT_URI='your-app-redirect-url' 93 | 94 | 95 | Scopes 96 | ------ 97 | 98 | See `Using 99 | Scopes `_ for information 100 | about scopes. 101 | 102 | Redirect URI 103 | ------------ 104 | 105 | The **Authorization Code Flow** needs you to add a **redirect URI** 106 | to your application at 107 | `My Dashboard `_ 108 | (navigate to your application and then *[Edit Settings]*). 109 | 110 | The ``redirect_uri`` argument or ``SPOTIPY_REDIRECT_URI`` environment variable 111 | must match the redirect URI added to your application in your Dashboard. 112 | The redirect URI can be any valid URI (it does not need to be accessible) 113 | such as ``http://example.com`` or ``http://127.0.0.1:9090``. 114 | 115 | .. note:: If you choose an `http`-scheme URL, and it's for 116 | `127.0.0.1`, **AND** it specifies a port, then spotipy will instantiate 117 | a server on the indicated response to receive the access token from the 118 | response at the end of the oauth flow [see the code](https://github.com/plamere/spotipy/blob/master/spotipy/oauth2.py#L483-L490). 119 | 120 | 121 | Client Credentials Flow 122 | ======================= 123 | 124 | The Client Credentials flow is used in server-to-server authentication. Only 125 | endpoints that do not access user information can be accessed. The advantage here 126 | in comparison with requests to the Web API made without an access token, 127 | is that a higher rate limit is applied. 128 | 129 | As opposed to the Authorization Code Flow, you will not need to set ``SPOTIPY_REDIRECT_URI``, 130 | which means you will never be redirected to the sign in page in your browser:: 131 | 132 | export SPOTIPY_CLIENT_ID='your-spotify-client-id' 133 | export SPOTIPY_CLIENT_SECRET='your-spotify-client-secret' 134 | 135 | To support the **Client Credentials Flow** *Spotipy* provides a 136 | class SpotifyClientCredentials that can be used to authenticate requests like so:: 137 | 138 | 139 | import spotipy 140 | from spotipy.oauth2 import SpotifyClientCredentials 141 | 142 | auth_manager = SpotifyClientCredentials() 143 | sp = spotipy.Spotify(auth_manager=auth_manager) 144 | 145 | playlists = sp.user_playlists('spotify') 146 | while playlists: 147 | for i, playlist in enumerate(playlists['items']): 148 | print(f"{i + 1 + playlists['offset']:4d} {playlist['uri']} {playlist['name']}") 149 | if playlists['next']: 150 | playlists = sp.next(playlists) 151 | else: 152 | playlists = None 153 | 154 | 155 | IDs URIs and URLs 156 | ================= 157 | 158 | *Spotipy* supports a number of different ID types: 159 | 160 | - **Spotify URI** - The resource identifier that you can enter, for example, in 161 | the Spotify Desktop client's search box to locate an artist, album, or 162 | track. Example: ``spotify:track:6rqhFgbbKwnb9MLmUQDhG6`` 163 | - **Spotify URL** - An HTML link that opens a track, album, app, playlist or other 164 | Spotify resource in a Spotify client. Example: 165 | ``http://open.spotify.com/track/6rqhFgbbKwnb9MLmUQDhG6`` 166 | - **Spotify ID** - A base-62 number that you can find at the end of the Spotify 167 | URI (see above) for an artist, track, album, etc. Example: 168 | ``6rqhFgbbKwnb9MLmUQDhG6`` 169 | 170 | In general, any *Spotipy* method that needs an artist, album, track or playlist ID 171 | will accept ids in any of the above form 172 | 173 | 174 | Customized token caching 175 | ======================== 176 | 177 | Tokens are refreshed automatically and stored by default in the project main folder. 178 | As this might not suit everyone's needs, spotipy provides a way to create customized 179 | cache handlers. 180 | 181 | https://github.com/plamere/spotipy/blob/master/spotipy/cache_handler.py 182 | 183 | The custom cache handler would need to be a class that inherits from the base 184 | cache handler ``CacheHandler``. The default cache handler ``CacheFileHandler`` is a good example. 185 | An instance of that new class can then be passed as a parameter when 186 | creating ``SpotifyOAuth``, ``SpotifyPKCE`` or ``SpotifyImplicitGrant``. 187 | The following handlers are available and defined in the URL above. 188 | 189 | - ``CacheFileHandler`` 190 | - ``MemoryCacheHandler`` 191 | - ``DjangoSessionCacheHandler`` 192 | - ``FlaskSessionCacheHandler`` 193 | - ``RedisCacheHandler`` 194 | - ``MemcacheCacheHandler``: install with dependency using ``pip install "spotipy[pymemcache]"`` 195 | 196 | Feel free to contribute new cache handlers to the repo. 197 | 198 | 199 | Examples 200 | ======================= 201 | 202 | Here is an example of using *Spotipy* to list the 203 | names of all the albums released by the artist 'Birdy':: 204 | 205 | import spotipy 206 | from spotipy.oauth2 import SpotifyClientCredentials 207 | 208 | birdy_uri = 'spotify:artist:2WX2uTcsvV5OnS0inACecP' 209 | spotify = spotipy.Spotify(client_credentials_manager=SpotifyClientCredentials()) 210 | 211 | results = spotify.artist_albums(birdy_uri, album_type='album') 212 | albums = results['items'] 213 | while results['next']: 214 | results = spotify.next(results) 215 | albums.extend(results['items']) 216 | 217 | for album in albums: 218 | print(album['name']) 219 | 220 | Here's another example showing how to get 30 second samples and cover art 221 | for the top 10 tracks for Led Zeppelin:: 222 | 223 | import spotipy 224 | from spotipy.oauth2 import SpotifyClientCredentials 225 | 226 | lz_uri = 'spotify:artist:36QJpDe2go2KgaRleHCDTp' 227 | 228 | spotify = spotipy.Spotify(client_credentials_manager=SpotifyClientCredentials()) 229 | results = spotify.artist_top_tracks(lz_uri) 230 | 231 | for track in results['tracks'][:10]: 232 | print('track : ' + track['name']) 233 | print('audio : ' + track['preview_url']) 234 | print('cover art: ' + track['album']['images'][0]['url']) 235 | print() 236 | 237 | Finally, here's an example that will get the URL for an artist image given the 238 | artist's name:: 239 | 240 | import spotipy 241 | import sys 242 | from spotipy.oauth2 import SpotifyClientCredentials 243 | 244 | spotify = spotipy.Spotify(auth_manager=SpotifyClientCredentials()) 245 | 246 | if len(sys.argv) > 1: 247 | name = ' '.join(sys.argv[1:]) 248 | else: 249 | name = 'Radiohead' 250 | 251 | results = spotify.search(q='artist:' + name, type='artist') 252 | items = results['artists']['items'] 253 | if len(items) > 0: 254 | artist = items[0] 255 | print(artist['name'], artist['images'][0]['url']) 256 | 257 | There are many more examples of how to use *Spotipy* in the `spotipy-examples 258 | repository `_ on GitHub. 259 | 260 | API Reference 261 | ============== 262 | 263 | :mod:`client` Module 264 | ======================= 265 | 266 | .. automodule:: spotipy.client 267 | :members: 268 | :undoc-members: 269 | :special-members: __init__ 270 | :show-inheritance: 271 | 272 | :mod:`oauth2` Module 273 | ======================= 274 | 275 | .. automodule:: spotipy.oauth2 276 | :members: 277 | :undoc-members: 278 | :special-members: __init__ 279 | :show-inheritance: 280 | 281 | :mod:`util` Module 282 | -------------------- 283 | 284 | .. automodule:: spotipy.util 285 | :members: 286 | :undoc-members: 287 | :special-members: __init__ 288 | :show-inheritance: 289 | 290 | 291 | Support 292 | ======= 293 | You can ask questions about Spotipy on Stack Overflow. Don’t forget to add the 294 | *Spotipy* tag, and any other relevant tags as well, before posting. 295 | 296 | http://stackoverflow.com/questions/ask 297 | 298 | If you think you've found a bug, let us know at 299 | `Spotipy Issues `_ 300 | 301 | 302 | Contribute 303 | ========== 304 | 305 | If you are a developer with Python experience, and you would like to contribute to Spotipy, please 306 | be sure to follow the guidelines listed below: 307 | 308 | Export the needed Environment variables::: 309 | export SPOTIPY_CLIENT_ID=client_id_here 310 | export SPOTIPY_CLIENT_SECRET=client_secret_here 311 | export SPOTIPY_CLIENT_USERNAME=client_username_here # This is actually an id not spotify display name 312 | export SPOTIPY_REDIRECT_URI=http://127.0.0.1:8080 # Make url is set in app you created to get your ID and SECRET 313 | 314 | Create virtual environment, install dependencies, run tests::: 315 | $ virtualenv --python=python3.12 env 316 | (env) $ pip install --user -e . 317 | (env) $ python -m unittest discover -v tests 318 | 319 | **Lint** 320 | 321 | To automatically fix the code style::: 322 | pip install autopep8 323 | autopep8 --in-place --aggressive --recursive . 324 | 325 | To verify the code style::: 326 | pip install flake8 327 | flake8 . 328 | 329 | To make sure if the import lists are stored correctly::: 330 | pip install isort 331 | isort . -c -v 332 | 333 | **Publishing (by maintainer)** 334 | 335 | - Bump version in setup.py 336 | - Bump and date changelog 337 | - Add to changelog: 338 | :: 339 | ## Unreleased 340 | 341 | // Add your changes here and then delete this line 342 | - Commit changes 343 | - Package to pypi: 344 | :: 345 | python setup.py sdist bdist_wheel 346 | python3 setup.py sdist bdist_wheel 347 | twine check dist/* 348 | twine upload --repository-url https://upload.pypi.org/legacy/ --skip-existing dist/*.(whl|gz|zip)~dist/*linux*.whl 349 | - 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 350 | - Build the documentation again to ensure it's on the latest version 351 | 352 | **Changelog** 353 | 354 | Don't forget to add a short description of your change in the `CHANGELOG `_! 355 | 356 | 357 | 358 | License 359 | ======= 360 | (Taken from https://github.com/plamere/spotipy/blob/master/LICENSE.md):: 361 | 362 | MIT License 363 | Copyright (c) 2021 Paul Lamere 364 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files 365 | (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, 366 | publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do 367 | so, subject to the following conditions: 368 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 369 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 370 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 371 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 372 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 373 | 374 | 375 | Indices and tables 376 | ================== 377 | 378 | * :ref:`genindex` 379 | * :ref:`modindex` 380 | * :ref:`search` 381 | -------------------------------------------------------------------------------- /tests/integration/non_user_endpoints/test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import requests 4 | 5 | import spotipy 6 | from spotipy import Spotify, SpotifyClientCredentials, SpotifyException 7 | 8 | 9 | class AuthTestSpotipy(unittest.TestCase): 10 | """ 11 | These tests require client authentication - provide client credentials 12 | using the following environment variables 13 | 14 | :: 15 | 16 | 'SPOTIPY_CLIENT_ID' 17 | 'SPOTIPY_CLIENT_SECRET' 18 | """ 19 | 20 | playlist = "spotify:user:plamere:playlist:2oCEWyyAPbZp9xhVSxZavx" 21 | four_tracks = ["spotify:track:6RtPijgfPKROxEzTHNRiDp", 22 | "spotify:track:7IHOIqZUUInxjVkko181PB", 23 | "4VrWlk8IQxevMvERoX08iC", 24 | "http://open.spotify.com/track/3cySlItpiPiIAzU3NyHCJf"] 25 | 26 | two_tracks = ["spotify:track:6RtPijgfPKROxEzTHNRiDp", 27 | "spotify:track:7IHOIqZUUInxjVkko181PB"] 28 | 29 | other_tracks = ["spotify:track:2wySlB6vMzCbQrRnNGOYKa", 30 | "spotify:track:29xKs5BAHlmlX1u4gzQAbJ", 31 | "spotify:track:1PB7gRWcvefzu7t3LJLUlf"] 32 | 33 | bad_id = 'BAD_ID' 34 | 35 | creep_urn = 'spotify:track:6b2oQwSGFkzsMtQruIWm2p' 36 | creep_id = '6b2oQwSGFkzsMtQruIWm2p' 37 | creep_url = 'http://open.spotify.com/track/6b2oQwSGFkzsMtQruIWm2p' 38 | 39 | el_scorcho_urn = 'spotify:track:0Svkvt5I79wficMFgaqEQJ' 40 | el_scorcho_bad_urn = 'spotify:track:0Svkvt5I79wficMFgaqEQK' 41 | pinkerton_urn = 'spotify:album:04xe676vyiTeYNXw15o9jT' 42 | weezer_urn = 'spotify:artist:3jOstUTkEu2JkjvRdBA5Gu' 43 | 44 | pablo_honey_urn = 'spotify:album:6AZv3m27uyRxi8KyJSfUxL' 45 | radiohead_urn = 'spotify:artist:4Z8W4fKeB5YxbusRsdQVPb' 46 | radiohead_id = "4Z8W4fKeB5YxbusRsdQVPb" 47 | radiohead_url = "https://open.spotify.com/artist/4Z8W4fKeB5YxbusRsdQVPb" 48 | 49 | qotsa_url = "https://open.spotify.com/artist/4pejUc4iciQfgdX6OKulQn" 50 | 51 | angeles_haydn_urn = 'spotify:album:1vAbqAeuJVWNAe7UR00bdM' 52 | heavyweight_urn = 'spotify:show:5c26B28vZMN8PG0Nppmn5G' 53 | heavyweight_id = '5c26B28vZMN8PG0Nppmn5G' 54 | heavyweight_url = 'https://open.spotify.com/show/5c26B28vZMN8PG0Nppmn5G' 55 | reply_all_urn = 'spotify:show:7gozmLqbcbr6PScMjc0Zl4' 56 | heavyweight_ep1_urn = 'spotify:episode:68kq3bNz6hEuq8NtdfwERG' 57 | heavyweight_ep1_id = '68kq3bNz6hEuq8NtdfwERG' 58 | heavyweight_ep1_url = 'https://open.spotify.com/episode/68kq3bNz6hEuq8NtdfwERG' 59 | reply_all_ep1_urn = 'spotify:episode:1KHjbpnmNpFmNTczQmTZlR' 60 | 61 | dune_urn = 'spotify:audiobook:7iHfbu1YPACw6oZPAFJtqe' 62 | dune_id = '7iHfbu1YPACw6oZPAFJtqe' 63 | dune_url = 'https://open.spotify.com/audiobook/7iHfbu1YPACw6oZPAFJtqe' 64 | two_books = [ 65 | 'spotify:audiobook:7iHfbu1YPACw6oZPAFJtqe', 66 | 'spotify:audiobook:67VtmjZitn25TWocsyAEyh'] 67 | 68 | @classmethod 69 | def setUpClass(self): 70 | self.spotify = Spotify( 71 | client_credentials_manager=SpotifyClientCredentials()) 72 | self.spotify.trace = False 73 | 74 | def test_artist_urn(self): 75 | artist = self.spotify.artist(self.radiohead_urn) 76 | self.assertTrue(artist['name'] == 'Radiohead') 77 | 78 | def test_artist_url(self): 79 | artist = self.spotify.artist(self.radiohead_url) 80 | self.assertTrue(artist['name'] == 'Radiohead') 81 | 82 | def test_artist_id(self): 83 | artist = self.spotify.artist(self.radiohead_id) 84 | self.assertTrue(artist['name'] == 'Radiohead') 85 | 86 | def test_artists(self): 87 | results = self.spotify.artists([self.weezer_urn, self.radiohead_urn]) 88 | self.assertTrue('artists' in results) 89 | self.assertTrue(len(results['artists']) == 2) 90 | 91 | def test_artists_mixed_ids(self): 92 | results = self.spotify.artists([self.weezer_urn, self.radiohead_id, self.qotsa_url]) 93 | self.assertTrue('artists' in results) 94 | self.assertTrue(len(results['artists']) == 3) 95 | 96 | def test_album_urn(self): 97 | album = self.spotify.album(self.pinkerton_urn) 98 | self.assertTrue(album['name'] == 'Pinkerton') 99 | 100 | def test_album_tracks(self): 101 | results = self.spotify.album_tracks(self.pinkerton_urn) 102 | self.assertTrue(len(results['items']) == 10) 103 | 104 | def test_album_tracks_many(self): 105 | results = self.spotify.album_tracks(self.angeles_haydn_urn) 106 | tracks = results['items'] 107 | total, received = results['total'], len(tracks) 108 | while received < total: 109 | results = self.spotify.album_tracks( 110 | self.angeles_haydn_urn, offset=received) 111 | tracks.extend(results['items']) 112 | received = len(tracks) 113 | 114 | self.assertEqual(received, total) 115 | 116 | def test_albums(self): 117 | results = self.spotify.albums( 118 | [self.pinkerton_urn, self.pablo_honey_urn]) 119 | self.assertTrue('albums' in results) 120 | self.assertTrue(len(results['albums']) == 2) 121 | 122 | def test_track_urn(self): 123 | track = self.spotify.track(self.creep_urn) 124 | self.assertTrue(track['name'] == 'Creep') 125 | 126 | def test_track_id(self): 127 | track = self.spotify.track(self.creep_id) 128 | self.assertTrue(track['name'] == 'Creep') 129 | self.assertTrue(track['popularity'] > 0) 130 | 131 | def test_track_url(self): 132 | track = self.spotify.track(self.creep_url) 133 | self.assertTrue(track['name'] == 'Creep') 134 | 135 | def test_track_bad_urn(self): 136 | try: 137 | self.spotify.track(self.el_scorcho_bad_urn) 138 | self.assertTrue(False) 139 | except SpotifyException: 140 | self.assertTrue(True) 141 | 142 | def test_tracks(self): 143 | results = self.spotify.tracks([self.creep_url, self.el_scorcho_urn]) 144 | self.assertTrue('tracks' in results) 145 | self.assertTrue(len(results['tracks']) == 2) 146 | 147 | def test_artist_top_tracks(self): 148 | results = self.spotify.artist_top_tracks(self.weezer_urn) 149 | self.assertTrue('tracks' in results) 150 | self.assertTrue(len(results['tracks']) == 10) 151 | 152 | def test_artist_search(self): 153 | results = self.spotify.search(q='weezer', type='artist') 154 | self.assertTrue('artists' in results) 155 | self.assertTrue(len(results['artists']['items']) > 0) 156 | self.assertTrue(results['artists']['items'][0]['name'] == 'Weezer') 157 | 158 | def test_artist_search_with_market(self): 159 | results = self.spotify.search(q='weezer', type='artist', market='GB') 160 | self.assertTrue('artists' in results) 161 | self.assertTrue(len(results['artists']['items']) > 0) 162 | self.assertTrue(results['artists']['items'][0]['name'] == 'Weezer') 163 | 164 | def test_artist_search_with_multiple_markets(self): 165 | total = 5 166 | countries_list = ['GB', 'US', 'AU'] 167 | countries_tuple = ('GB', 'US', 'AU') 168 | 169 | results_multiple = self.spotify.search_markets(q='weezer', type='artist', 170 | markets=countries_list) 171 | results_all = self.spotify.search_markets(q='weezer', type='artist') 172 | results_tuple = self.spotify.search_markets(q='weezer', type='artist', 173 | markets=countries_tuple) 174 | results_limited = self.spotify.search_markets(q='weezer', limit=3, type='artist', 175 | markets=countries_list, total=total) 176 | 177 | self.assertTrue( 178 | all('artists' in results_multiple[country] for country in results_multiple)) 179 | self.assertTrue(all('artists' in results_all[country] for country in results_all)) 180 | self.assertTrue(all('artists' in results_tuple[country] for country in results_tuple)) 181 | self.assertTrue(all('artists' in results_limited[country] for country in results_limited)) 182 | 183 | self.assertTrue( 184 | all(len(results_multiple[country]['artists']['items']) > 0 for country in 185 | results_multiple)) 186 | self.assertTrue(all(len(results_all[country]['artists'] 187 | ['items']) > 0 for country in results_all)) 188 | self.assertTrue( 189 | all(len(results_tuple[country]['artists']['items']) > 0 for country in results_tuple)) 190 | self.assertTrue( 191 | all(len(results_limited[country]['artists']['items']) > 0 for country in 192 | results_limited)) 193 | 194 | self.assertTrue(all(results_multiple[country]['artists']['items'] 195 | [0]['name'] == 'Weezer' for country in results_multiple)) 196 | self.assertTrue(all(results_all[country]['artists']['items'] 197 | [0]['name'] == 'Weezer' for country in results_all)) 198 | self.assertTrue(all(results_tuple[country]['artists']['items'] 199 | [0]['name'] == 'Weezer' for country in results_tuple)) 200 | self.assertTrue(all(results_limited[country]['artists']['items'] 201 | [0]['name'] == 'Weezer' for country in results_limited)) 202 | 203 | total_limited_results = 0 204 | for country in results_limited: 205 | total_limited_results += len(results_limited[country]['artists']['items']) 206 | self.assertTrue(total_limited_results <= total) 207 | 208 | def test_multiple_types_search_with_multiple_markets(self): 209 | total = 14 210 | 211 | countries_list = ['GB', 'US', 'AU'] 212 | countries_tuple = ('GB', 'US', 'AU') 213 | 214 | results_multiple = self.spotify.search_markets(q='abba', type='artist,track', 215 | markets=countries_list) 216 | results_all = self.spotify.search_markets(q='abba', type='artist,track') 217 | results_tuple = self.spotify.search_markets(q='abba', type='artist,track', 218 | markets=countries_tuple) 219 | results_limited = self.spotify.search_markets(q='abba', limit=3, type='artist,track', 220 | markets=countries_list, total=total) 221 | 222 | # Asserts 'artists' property is present in all responses 223 | self.assertTrue( 224 | all('artists' in results_multiple[country] for country in results_multiple)) 225 | self.assertTrue(all('artists' in results_all[country] for country in results_all)) 226 | self.assertTrue(all('artists' in results_tuple[country] for country in results_tuple)) 227 | self.assertTrue(all('artists' in results_limited[country] for country in results_limited)) 228 | 229 | # Asserts 'tracks' property is present in all responses 230 | self.assertTrue( 231 | all('tracks' in results_multiple[country] for country in results_multiple)) 232 | self.assertTrue(all('tracks' in results_all[country] for country in results_all)) 233 | self.assertTrue(all('tracks' in results_tuple[country] for country in results_tuple)) 234 | self.assertTrue(all('tracks' in results_limited[country] for country in results_limited)) 235 | 236 | # Asserts 'artists' list is nonempty in unlimited searches 237 | self.assertTrue( 238 | all(len(results_multiple[country]['artists']['items']) > 0 for country in 239 | results_multiple)) 240 | self.assertTrue(all(len(results_all[country]['artists'] 241 | ['items']) > 0 for country in results_all)) 242 | self.assertTrue( 243 | all(len(results_tuple[country]['artists']['items']) > 0 for country in results_tuple)) 244 | 245 | # Asserts 'tracks' list is nonempty in unlimited searches 246 | self.assertTrue( 247 | all(len(results_multiple[country]['tracks']['items']) > 0 for country in 248 | results_multiple)) 249 | self.assertTrue(all(len(results_all[country]['tracks'] 250 | ['items']) > 0 for country in results_all)) 251 | self.assertTrue(all(len(results_tuple[country]['tracks'] 252 | ['items']) > 0 for country in results_tuple)) 253 | 254 | # Asserts artist name is the first artist result in all searches 255 | self.assertTrue(all(results_multiple[country]['artists']['items'] 256 | [0]['name'] == 'ABBA' for country in results_multiple)) 257 | self.assertTrue(all(results_all[country]['artists']['items'] 258 | [0]['name'] == 'ABBA' for country in results_all)) 259 | self.assertTrue(all(results_tuple[country]['artists']['items'] 260 | [0]['name'] == 'ABBA' for country in results_tuple)) 261 | self.assertTrue(all(results_limited[country]['artists']['items'] 262 | [0]['name'] == 'ABBA' for country in results_limited)) 263 | 264 | # Asserts track name is present in responses from specified markets 265 | self.assertTrue(all('Dancing Queen' in 266 | [item['name'] for item in results_multiple[country]['tracks']['items']] 267 | for country in results_multiple)) 268 | self.assertTrue(all('Dancing Queen' in 269 | [item['name'] for item in results_tuple[country]['tracks']['items']] 270 | for country in results_tuple)) 271 | 272 | # Asserts expected number of items are returned based on the total 273 | # 3 artists + 3 tracks = 6 items returned from first market 274 | # 3 artists + 3 tracks = 6 items returned from second market 275 | # 2 artists + 0 tracks = 2 items returned from third market 276 | # 14 items returned total 277 | self.assertEqual(len(results_limited['GB']['artists']['items']), 3) 278 | self.assertEqual(len(results_limited['GB']['tracks']['items']), 3) 279 | self.assertEqual(len(results_limited['US']['artists']['items']), 3) 280 | self.assertEqual(len(results_limited['US']['tracks']['items']), 3) 281 | self.assertEqual(len(results_limited['AU']['artists']['items']), 2) 282 | self.assertEqual(len(results_limited['AU']['tracks']['items']), 0) 283 | 284 | item_count = sum([len(market_result['artists']['items']) + len(market_result['tracks'] 285 | ['items']) for market_result in results_limited.values()]) 286 | 287 | self.assertEqual(item_count, total) 288 | 289 | def test_artist_albums(self): 290 | results = self.spotify.artist_albums(self.weezer_urn) 291 | self.assertTrue('items' in results) 292 | self.assertTrue(len(results['items']) > 0) 293 | 294 | def find_album(): 295 | for album in results['items']: 296 | if 'Weezer' in album['name']: # Weezer has many albums containing Weezer 297 | return True 298 | return False 299 | 300 | self.assertTrue(find_album()) 301 | 302 | def test_search_timeout(self): 303 | client_credentials_manager = SpotifyClientCredentials() 304 | sp = spotipy.Spotify(requests_timeout=0.01, 305 | client_credentials_manager=client_credentials_manager) 306 | 307 | # depending on the timing or bandwidth, this raises a timeout or connection error 308 | self.assertRaises((requests.exceptions.Timeout, requests.exceptions.ConnectionError), 309 | lambda: sp.search(q='my*', type='track')) 310 | 311 | @unittest.skip("flaky test, need a better method to test retries") 312 | def test_max_retries_reached_get(self): 313 | spotify_no_retry = Spotify( 314 | client_credentials_manager=SpotifyClientCredentials(), 315 | retries=0) 316 | i = 0 317 | while i < 100: 318 | try: 319 | spotify_no_retry.search(q='foo') 320 | except SpotifyException as e: 321 | self.assertIsInstance(e, SpotifyException) 322 | self.assertEqual(e.http_status, 429) 323 | return 324 | i += 1 325 | self.fail() 326 | 327 | def test_album_search(self): 328 | results = self.spotify.search(q='weezer pinkerton', type='album') 329 | self.assertTrue('albums' in results) 330 | self.assertTrue(len(results['albums']['items']) > 0) 331 | self.assertTrue(results['albums']['items'][0] 332 | ['name'].find('Pinkerton') >= 0) 333 | 334 | def test_track_search(self): 335 | results = self.spotify.search(q='el scorcho weezer', type='track') 336 | self.assertTrue('tracks' in results) 337 | self.assertTrue(len(results['tracks']['items']) > 0) 338 | self.assertTrue(results['tracks']['items'][0]['name'] == 'El Scorcho') 339 | 340 | def test_user(self): 341 | user = self.spotify.user(user='plamere') 342 | self.assertTrue(user['uri'] == 'spotify:user:plamere') 343 | 344 | def test_track_bad_id(self): 345 | try: 346 | self.spotify.track(self.bad_id) 347 | self.assertTrue(False) 348 | except SpotifyException: 349 | self.assertTrue(True) 350 | 351 | def test_show_urn(self): 352 | show = self.spotify.show(self.heavyweight_urn, market="US") 353 | self.assertTrue(show['name'] == 'Heavyweight') 354 | 355 | def test_show_id(self): 356 | show = self.spotify.show(self.heavyweight_id, market="US") 357 | self.assertTrue(show['name'] == 'Heavyweight') 358 | 359 | def test_show_url(self): 360 | show = self.spotify.show(self.heavyweight_url, market="US") 361 | self.assertTrue(show['name'] == 'Heavyweight') 362 | 363 | def test_show_bad_urn(self): 364 | with self.assertRaises(SpotifyException): 365 | self.spotify.show("bogus_urn", market="US") 366 | 367 | def test_shows(self): 368 | results = self.spotify.shows([self.heavyweight_urn, self.reply_all_urn], market="US") 369 | self.assertTrue('shows' in results) 370 | self.assertTrue(len(results['shows']) == 2) 371 | 372 | def test_show_episodes(self): 373 | results = self.spotify.show_episodes(self.heavyweight_urn, market="US") 374 | self.assertTrue(len(results['items']) > 1) 375 | 376 | def test_show_episodes_many(self): 377 | results = self.spotify.show_episodes(self.reply_all_urn, market="US") 378 | episodes = results['items'] 379 | total, received = results['total'], len(episodes) 380 | while received < total: 381 | results = self.spotify.show_episodes( 382 | self.reply_all_urn, offset=received, market="US") 383 | episodes.extend(results['items']) 384 | received = len(episodes) 385 | 386 | self.assertEqual(received, total) 387 | 388 | def test_episode_urn(self): 389 | episode = self.spotify.episode(self.heavyweight_ep1_urn, market="US") 390 | self.assertTrue(episode['name'] == '#1 Buzz') 391 | 392 | def test_episode_id(self): 393 | episode = self.spotify.episode(self.heavyweight_ep1_id, market="US") 394 | self.assertTrue(episode['name'] == '#1 Buzz') 395 | 396 | def test_episode_url(self): 397 | episode = self.spotify.episode(self.heavyweight_ep1_url, market="US") 398 | self.assertTrue(episode['name'] == '#1 Buzz') 399 | 400 | def test_episode_bad_urn(self): 401 | with self.assertRaises(SpotifyException): 402 | self.spotify.episode("bogus_urn", market="US") 403 | 404 | def test_episodes(self): 405 | results = self.spotify.episodes( 406 | [self.heavyweight_ep1_urn, self.reply_all_ep1_urn], 407 | market="US" 408 | ) 409 | self.assertTrue('episodes' in results) 410 | self.assertTrue(len(results['episodes']) == 2) 411 | 412 | def test_unauthenticated_post_fails(self): 413 | with self.assertRaises(SpotifyException) as cm: 414 | self.spotify.user_playlist_create( 415 | "spotify", "Best hits of the 90s") 416 | self.assertTrue(cm.exception.http_status == 401 or cm.exception.http_status == 403) 417 | 418 | def test_custom_requests_session(self): 419 | sess = requests.Session() 420 | sess.headers["user-agent"] = "spotipy-test" 421 | with_custom_session = spotipy.Spotify( 422 | client_credentials_manager=SpotifyClientCredentials(), 423 | requests_session=sess) 424 | self.assertTrue( 425 | with_custom_session.user( 426 | user="akx")["uri"] == "spotify:user:akx") 427 | sess.close() 428 | 429 | def test_force_no_requests_session(self): 430 | with_no_session = spotipy.Spotify( 431 | client_credentials_manager=SpotifyClientCredentials(), 432 | requests_session=False) 433 | self.assertNotIsInstance(with_no_session._session, requests.Session) 434 | user = with_no_session.user(user="akx") 435 | self.assertEqual(user["uri"], "spotify:user:akx") 436 | 437 | def test_available_markets(self): 438 | markets = self.spotify.available_markets()["markets"] 439 | self.assertTrue(isinstance(markets, list)) 440 | self.assertIn("US", markets) 441 | self.assertIn("GB", markets) 442 | 443 | def test_get_audiobook(self): 444 | audiobook = self.spotify.get_audiobook(self.dune_urn, market="US") 445 | self.assertTrue(audiobook['name'] == 446 | 'Dune: Book One in the Dune Chronicles') 447 | 448 | def test_get_audiobook_bad_urn(self): 449 | with self.assertRaises(SpotifyException): 450 | self.spotify.get_audiobook("bogus_urn", market="US") 451 | 452 | def test_get_audiobooks(self): 453 | results = self.spotify.get_audiobooks(self.two_books, market="US") 454 | self.assertTrue('audiobooks' in results) 455 | self.assertTrue(len(results['audiobooks']) == 2) 456 | self.assertTrue(results['audiobooks'][0]['name'] 457 | == 'Dune: Book One in the Dune Chronicles') 458 | self.assertTrue(results['audiobooks'][1]['name'] == 'The Helper') 459 | 460 | def test_get_audiobook_chapters(self): 461 | results = self.spotify.get_audiobook_chapters( 462 | self.dune_urn, market="US", limit=10, offset=5) 463 | self.assertTrue('items' in results) 464 | self.assertTrue(len(results['items']) == 10) 465 | self.assertTrue(results['items'][0]['chapter_number'] == 5) 466 | self.assertTrue(results['items'][9]['chapter_number'] == 14) 467 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## Unreleased 9 | 10 | Add your changes below. 11 | 12 | ### Added 13 | 14 | ### Fixed 15 | 16 | ### Removed 17 | 18 | ## [2.25.2] - 2025-11-26 19 | 20 | ### Added 21 | 22 | - Adds `additional_types` parameter to retrieve currently playing episode 23 | - Add deprecation warnings to documentation 24 | 25 | ### Fixed 26 | 27 | - Fixed dead link in README.md 28 | - Corrected Spotify/Spotipy typo in documentation 29 | - Sanitize HTML error message output for OAuth flow: https://github.com/spotipy-dev/spotipy/security/advisories/GHSA-r77h-rpp9-w2xm 30 | 31 | ## [2.25.1] - 2025-02-27 32 | 33 | ### Added 34 | 35 | - Added examples for audiobooks, shows and episodes methods to examples directory 36 | 37 | ### Fixed 38 | 39 | - Fixed scripts in examples directory that didn't run correctly 40 | - Updated documentation for `Client.current_user_top_artists` to indicate maximum number of artists limit 41 | - Set auth cache file permissions to `600`: https://github.com/spotipy-dev/spotipy/security/advisories/GHSA-pwhh-q4h6-w599 42 | - Fixed `__del__` methods by preventing garbage collection for `requests.Session` 43 | - Improved retry warning by using `logger` instead of `logging` and making sure that `retry_header` is an int 44 | 45 | ### Changed 46 | 47 | - Updated get_cached_token and save_token_to_cache methods to utilize Python's Context Management Protocol 48 | - Added except clause to get_cached_token method to handle json decode errors 49 | - Added warnings and updated docs due to Spotify's deprecation of HTTP and "localhost" redirect URIs 50 | - Use newer string formatters () 51 | - Marked `recommendation_genre_seeds` as deprecated 52 | 53 | ## [2.25.0] - 2025-03-01 54 | 55 | ### Added 56 | 57 | - Added unit tests for queue functions 58 | - Added detailed function docstrings to 'util.py', including descriptions and special sections that lists arguments, returns, and raises. 59 | - Updated order of instructions for Python and pip package manager installation in TUTORIAL.md 60 | - Updated TUTORIAL.md instructions to match current layout of Spotify Developer Dashboard 61 | - Added test_artist_id, test_artist_url, and test_artists_mixed_ids to non_user_endpoints test.py 62 | - Added rate/request limit to FAQ 63 | - Added custom `urllib3.Retry` class for printing a warning when a rate/request limit is reached. 64 | - Added `personalized_playlist.py`, `track_recommendations.py`, and `audio_features_analysis.py` to `/examples`. 65 | - Discord badge in README 66 | - Added `SpotifyBaseException` and moved all exceptions to `exceptions.py` 67 | - Marked the following methods as deprecated: 68 | - artist_related_artists 69 | - recommendations 70 | - audio_features 71 | - audio_analysis 72 | - featured_playlists 73 | - category_playlists 74 | - Added FAQ entry for inaccessible playlists 75 | - Workflow to check for f-strings 76 | 77 | ### Changed 78 | 79 | - Split test and lint workflows 80 | - Updated get_cached_token and save_token_to_cache methods to utilize Python's Context Management Protocol 81 | - Added except clause to get_cached_token method to handle json decode errors 82 | 83 | ### Fixed 84 | 85 | - Audiobook integration tests 86 | - Edited docstrings for certain functions in client.py for functions that are no longer in use and have been replaced. 87 | - `current_user_unfollow_playlist()` now supports playlist IDs, URLs, and URIs rather than previously where it only supported playlist IDs. 88 | 89 | ### Removed 90 | 91 | - `mock` no longer listed as a test dependency. Only built-in `unittest.mock` is actually used. 92 | 93 | ## [2.24.0] - 2024-05-30 94 | 95 | ### Added 96 | 97 | - Added `MemcacheCacheHandler`, a cache handler that stores the token info using pymemcache. 98 | - Added support for audiobook endpoints: `get_audiobook`, `get_audiobooks`, and `get_audiobook_chapters`. 99 | - Added integration tests for audiobook endpoints. 100 | - Added `update` field to `current_user_follow_playlist`. 101 | 102 | ### Changed 103 | 104 | - Updated get_cached_token and save_token_to_cache methods to utilize Python's Context Management Protocol 105 | - Added except clause to get_cached_token method to handle json decode errors 106 | 107 | - Fixed error obfuscation when Spotify class is being inherited and an error is raised in the Child's `__init__` 108 | - Replaced `artist_albums(album_type=...)` with `artist_albums(include_groups=...)` due to an API change. 109 | - Updated `_regex_spotify_url` to ignore `/intl-` in Spotify links 110 | - Improved README, docs and examples 111 | 112 | ### Fixed 113 | 114 | - Readthedocs build 115 | - Split `test_current_user_save_and_usave_tracks` unit test 116 | 117 | ### Removed 118 | 119 | - Drop support for EOL Python 3.7 120 | 121 | ## [2.23.0] - 2023-04-07 122 | 123 | ### Added 124 | 125 | - Added optional `encoder_cls` argument to `CacheFileHandler`, which overwrite default encoder for token before writing to disk 126 | - Integration tests for searching multiple types in multiple markets (non-user endpoints) 127 | - Publish to PyPI action 128 | 129 | ### Fixed 130 | 131 | - Fixed the regex for matching playlist URIs with the format spotify:user:USERNAME:playlist:PLAYLISTID. 132 | - `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)) 133 | 134 | ## [2.22.1] - 2023-01-23 135 | 136 | ### Added 137 | 138 | - Add alternative module installation instruction to README 139 | - Added Comment to README - Getting Started for user to add URI to app in Spotify Developer Dashboard. 140 | - Added playlist_add_tracks.py to example folder 141 | 142 | ### Changed 143 | 144 | - Modified docstring for playlist_add_items() to accept "only URIs or URLs", 145 | with intended deprecation for IDs in v3 146 | 147 | ### Fixed 148 | 149 | - Path traversal vulnerability that may lead to type confusion in URI handling code 150 | - Update contributing.md 151 | 152 | ## [2.22.0] - 2022-12-10 153 | 154 | ### Added 155 | 156 | - Integration tests via GHA (non-user endpoints) 157 | - Unit tests for new releases, passing limit parameter with minimum and maximum values of 1 and 50 158 | - Unit tests for categories, omitting country code to test global releases 159 | - Added `CODE_OF_CONDUCT.md` 160 | 161 | ### Fixed 162 | 163 | - Incorrect `category_id` input for test_category 164 | - Assertion value for `test_categories_limit_low` and `test_categories_limit_high` 165 | - Pin GitHub Actions Runner to Ubuntu 20 for Py27 166 | - Fixed potential error where `found` variable in `test_artist_related_artists` is undefined if for loop never evaluates to true 167 | - Fixed false positive test `test_new_releases` which looks up the wrong property of the JSON response object and always evaluates to true 168 | 169 | ## [2.21.0] - 2022-09-26 170 | 171 | ### Added 172 | 173 | - Added `market` parameter to `album` and `albums` to address ([#753](https://github.com/plamere/spotipy/issues/753) 174 | - Added `show_featured_artists.py` to `/examples`. 175 | - Expanded contribution and license sections of the documentation. 176 | - Added `FlaskSessionCacheHandler`, a cache handler that stores the token info in a flask session. 177 | - Added Python 3.10 in GitHub Actions 178 | 179 | ### Fixed 180 | 181 | - Updated the documentation to specify ISO-639-1 language codes. 182 | - Fix `AttributeError` for `text` attribute of the `Response` object 183 | - Require redis v3 if python2.7 (fixes readthedocs) 184 | 185 | ## [2.20.0] - 2022-06-18 186 | 187 | ### Added 188 | 189 | - Added `RedisCacheHandler`, a cache handler that stores the token info in Redis. 190 | - Changed URI handling in `client.Spotify._get_id()` to remove queries if provided by error. 191 | - Added a new parameter to `RedisCacheHandler` to allow custom keys (instead of the default `token_info` key) 192 | - Simplify check for existing token in `RedisCacheHandler` 193 | 194 | ### Changed 195 | 196 | - Removed Python 3.5 and added Python 3.9 in GitHub Action 197 | 198 | ## [2.19.0] - 2021-08-12 199 | 200 | ### Added 201 | 202 | - Added `MemoryCacheHandler`, a cache handler that simply stores the token info in memory as an instance attribute of this class. 203 | - If a network request returns an error status code but the response body cannot be decoded into JSON, then fall back on decoding the body into a string. 204 | - Added `DjangoSessionCacheHandler`, a cache handler that stores the token in the session framework provided by Django. Web apps using spotipy with Django can directly use this for cache handling. 205 | 206 | ### Fixed 207 | 208 | - Fixed a bug in `CacheFileHandler.__init__`: The documentation says that the username will be retrieved from the environment, but it wasn't. 209 | - 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. 210 | - Use generated MIT license and fix license type in `pip show` 211 | 212 | ## [2.18.0] - 2021-04-13 213 | 214 | ### Added 215 | 216 | - Enabled using both short and long IDs for playlist_change_details 217 | - Added a cache handler to `SpotifyClientCredentials` 218 | - Added the following endpoints 219 | - `Spotify.current_user_saved_episodes` 220 | - `Spotify.current_user_saved_episodes_add` 221 | - `Spotify.current_user_saved_episodes_delete` 222 | - `Spotify.current_user_saved_episodes_contains` 223 | - `Spotify.available_markets` 224 | 225 | ### Changed 226 | 227 | - Add support for a list of scopes rather than just a comma separated string of scopes 228 | 229 | ### Fixed 230 | 231 | - Fixed the bugs in `SpotifyOAuth.refresh_access_token` and `SpotifyPKCE.refresh_access_token` which raised the incorrect exception upon receiving an error response from the server. This addresses #645. 232 | - Fixed a bug in `RequestHandler.do_GET` in which the non-existent `state` attribute of `SpotifyOauthError` is accessed. This bug occurs when the user clicks "cancel" in the permissions dialog that opens in the browser. 233 | - Cleaned up the documentation for `SpotifyClientCredentials.__init__`, `SpotifyOAuth.__init__`, and `SpotifyPKCE.__init__`. 234 | 235 | ## [2.17.1] - 2021-02-28 236 | 237 | ### Fixed 238 | 239 | - `allowed_methods` requires urllib3>=1.26.0 240 | 241 | ## [2.17.0] - 2021-02-28 242 | 243 | ### Changed 244 | 245 | - moved os.remove(session_cache_path()) inside try block to avoid TypeError on app.py example file 246 | - A warning will no longer be emitted when the cache file does not exist at the specified path 247 | - The docs for the `auth` parameter of `Spotify.init` use the term "access token" instead of "authorization token" 248 | - Changed docs for `search` to mention that you can provide multiple types to search for 249 | - The query parameters of requests are now logged 250 | - Deprecate specifying `cache_path` or `username` directly to `SpotifyOAuth`, `SpotifyPKCE`, and `SpotifyImplicitGrant` constructors, instead directing users to use the `CacheFileHandler` cache handler 251 | - Removed requirement for examples/app.py to specify port multiple times (only SPOTIPY_REDIRECT_URI needs to contain the port) 252 | 253 | ### Added 254 | 255 | - Added log messages for when the access and refresh tokens are retrieved and when they are refreshed 256 | - Support `market` optional parameter in `track` 257 | - Added CacheHandler abstraction to allow users to cache tokens in any way they see fit 258 | 259 | ### Fixed 260 | 261 | - Fixed Spotify.user_playlist_reorder_tracks calling Spotify.playlist_reorder_tracks with an incorrect parameter order 262 | - Fixed deprecated Urllib3 `Retry(method_whitelist=...)` in favor of `Retry(allowed_methods=...)` 263 | 264 | ## [2.16.1] - 2020-10-24 265 | 266 | ### Fixed 267 | 268 | - playlist_tracks example code no longer prints extra characters on final loop iteration 269 | - SpotifyException now thrown when a request fails & has no response ([#571](https://github.com/plamere/spotipy/issues/571), [#581](https://github.com/plamere/spotipy/issues/581)) 270 | - Added scope, `playlist-read-private`, to examples that access user playlists using the spotipy api: current_user_playlists() ([#591](https://github.com/plamere/spotipy/issues/591)) 271 | - Enable retries for POST, DELETE, PUT ([#577](https://github.com/plamere/spotipy/issues/577)) 272 | 273 | ### Changed 274 | 275 | - both inline and starting import lists are sorted using `isort` module 276 | - changed Max Retries exception code from 599 to 429 277 | 278 | ## [2.16.0] - 2020-09-16 279 | 280 | ### Added 281 | 282 | - `open_browser` can be passed to the constructors of `SpotifyOAuth` and `SpotifyPKCE` to make it easier to authorize in browserless environments 283 | 284 | ## [2.15.0] - 2020-09-08 285 | 286 | ### Added 287 | 288 | - `SpotifyPKCE.parse_auth_response_url`, mirroring that method in 289 | `SpotifyOAuth` 290 | 291 | ### Changed 292 | 293 | - Specifying a cache_path or username is now optional 294 | 295 | ### Fixed 296 | 297 | - Using `SpotifyPKCE.get_authorization_url` will now generate a code 298 | challenge if needed 299 | 300 | ## [2.14.0] - 2020-08-29 301 | 302 | ### Added 303 | 304 | - (experimental) Support to search multiple/all markets at once. 305 | - Support to test whether the current user is following certain 306 | users or artists 307 | - Proper replacements for all deprecated playlist endpoints 308 | (See https://developer.spotify.com/community/news/2018/06/12/changes-to-playlist-uris/ and below) 309 | - Allow for OAuth 2.0 authorization by instructing the user to open the URL in a browser instead of opening the browser. 310 | - Reason for 403 error in SpotifyException 311 | - Support for the PKCE Auth Flow 312 | - Support to advertise different language to Spotify 313 | - Added 'collaborative' parameter to user_playlist_create method. 314 | - Enforce CHANGELOG update on PR 315 | - Adds `additional_types` parameter to retrieve currently playing podcast episode 316 | - Support to get info about a single category 317 | 318 | ### Deprecated 319 | 320 | - `user_playlist_change_details` in favor of `playlist_change_details` 321 | - `user_playlist_unfollow` in favor of `current_user_unfollow_playlist` 322 | - `user_playlist_add_tracks` in favor of `playlist_add_items` 323 | - `user_playlist_replace_tracks` in favor of `playlist_replace_items` 324 | - `user_playlist_reorder_tracks` in favor of `playlist_reorder_items` 325 | - `user_playlist_remove_all_occurrences_of_tracks` in favor of 326 | `playlist_remove_all_occurrences_of_items` 327 | - `user_playlist_remove_specific_occurrences_of_tracks` in favor of 328 | `playlist_remove_specific_occurrences_of_items` 329 | - `user_playlist_follow_playlist` in favor of 330 | `current_user_follow_playlist` 331 | - `user_playlist_is_following` in favor of `playlist_is_following` 332 | - `playlist_tracks` in favor of `playlist_items` 333 | 334 | ### Fixed 335 | 336 | - fixed issue where episode URIs were being converted to track URIs in playlist calls 337 | 338 | ## [2.13.0] - 2020-06-25 339 | 340 | ### Added 341 | 342 | - Added `SpotifyImplicitGrant` as an auth manager option. It provides 343 | user authentication without a client secret but sacrifices the ability 344 | to refresh the token without user input. (However, read the class 345 | docstring for security advisory.) 346 | - Added built-in verification of the `state` query parameter 347 | - Added two new attributes: error and error_description to `SpotifyOauthError` exception class to show 348 | authorization/authentication web api errors details. 349 | - Added `SpotifyStateError` subclass of `SpotifyOauthError` 350 | - Allow extending `SpotifyClientCredentials` and `SpotifyOAuth` 351 | - Added the market parameter to `album_tracks` 352 | 353 | ### Deprecated 354 | 355 | - Deprecated `util.prompt_for_user_token` in favor of `spotipy.Spotify(auth_manager=SpotifyOAuth())` 356 | 357 | ## [2.12.0] - 2020-04-26 358 | 359 | ### Added 360 | 361 | - Added a method to update the auth token. 362 | 363 | ### Fixed 364 | 365 | - Logging regression due to the addition of `logging.basicConfig()` which was unneeded. 366 | 367 | ## [2.11.2] - 2020-04-19 368 | 369 | ### Changed 370 | 371 | - Updated the documentation to give more details on the authorization process and reflect 372 | 2020 Spotify Application jargon and practices. 373 | 374 | - The local webserver is only started for localhost redirect_uri which specify a port, 375 | i.e. it is started for `http://localhost:8080` or `http://127.0.0.1:8080`, not for `http://localhost`. 376 | 377 | ### Fixed 378 | 379 | - Issue where using `http://localhost` as redirect_uri would cause the authorization process to hang. 380 | 381 | ## [2.11.1] - 2020-04-11 382 | 383 | ### Fixed 384 | 385 | - Fixed miscellaneous issues with parsing of callback URL 386 | 387 | ## [2.11.0] - 2020-04-11 388 | 389 | ### Added 390 | 391 | - Support for shows/podcasts and episodes 392 | - Added CONTRIBUTING.md 393 | 394 | ### Changed 395 | 396 | - Client retry logic has changed as it now uses urllib3's `Retry` in conjunction with requests `Session` 397 | - The session is customizable as it allows for: 398 | - status_forcelist 399 | - retries 400 | - status_retries 401 | - backoff_factor 402 | - Spin up a local webserver to autofill authentication URL 403 | - Use session in SpotifyAuthBase 404 | - Logging used instead of print statements 405 | 406 | ### Fixed 407 | 408 | - Close session when Spotipy object is unloaded 409 | - Propagate refresh token error 410 | 411 | ## [2.10.0] - 2020-03-18 412 | 413 | ### Added 414 | 415 | - Support for `add_to_queue` 416 | - **Parameters:** 417 | - track uri, id, or url 418 | - device id. If None, then the active device is used. 419 | - Add CHANGELOG and LICENSE to released package 420 | 421 | ## [2.9.0] - 2020-02-15 422 | 423 | ### Added 424 | 425 | - Support `position_ms` optional parameter in `start_playback` 426 | - Add `requests_timeout` parameter to authentication methods 427 | - Make cache optional in `get_access_token` 428 | 429 | ## [2.8.0] - 2020-02-12 430 | 431 | ### Added 432 | 433 | - Support for `playlist_cover_image` 434 | - Support `after` and `before` parameter in `current_user_recently_played` 435 | - CI for unit tests 436 | - Automatic `token` refresh 437 | - `auth_manager` and `oauth_manager` optional parameters added to `Spotify`'s init. 438 | - Optional `username` parameter to be passed to `SpotifyOAuth`, to infer a `cache_path` automatically 439 | - Optional `as_dict` parameter to control `SpotifyOAuth`'s `get_access_token` output type. However, this is going to be deprecated in the future, and the method will always return a token string 440 | - Optional `show_dialog` parameter to be passed to `SpotifyOAuth` 441 | 442 | ### Changed 443 | 444 | - Both `SpotifyClientCredentials` and `SpotifyOAuth` inherit from a common `SpotifyAuthBase` which handles common parameters and logics. 445 | 446 | ## [2.7.1] - 2020-01-20 447 | 448 | ### Changed 449 | 450 | - PyPi release mistake without pulling last merge first 451 | 452 | ## [2.7.0] - 2020-01-20 453 | 454 | ### Added 455 | 456 | - Support for `playlist_tracks` 457 | - Support for `playlist_upload_cover_image` 458 | 459 | ### Changed 460 | 461 | - `user_playlist_tracks` doesn't require a user anymore (accepts `None`) 462 | 463 | ### Deprecated 464 | 465 | - Deprecated `user_playlist` and `user_playlist_tracks` 466 | 467 | ## [2.6.3] - 2020-01-16 468 | 469 | ### Fixed 470 | 471 | - Fixed broken doc in 2.6.2 472 | 473 | ## [2.6.2] - 2020-01-16 474 | 475 | ### Fixed 476 | 477 | - Fixed broken examples in README, examples and doc 478 | 479 | ### Changed 480 | 481 | - Allow session keepalive 482 | - Bump requests to 2.20.0 483 | 484 | ## [2.6.1] - 2020-01-13 485 | 486 | ### Fixed 487 | 488 | - Fixed inconsistent behaviour with some API methods when 489 | a full HTTP URL is passed. 490 | - Fixed invalid calls to logging warn method 491 | 492 | ### Removed 493 | 494 | - `mock` no longer needed for install. Only used in `tox`. 495 | 496 | ## [2.6.0] - 2020-01-12 497 | 498 | ### Added 499 | 500 | - Support for `playlist` to get a playlist without specifying a user 501 | - Support for `current_user_saved_albums_delete` 502 | - Support for `current_user_saved_albums_contains` 503 | - Support for `user_unfollow_artists` 504 | - Support for `user_unfollow_users` 505 | - Lint with flake8 using GitHub action 506 | 507 | ### Changed 508 | 509 | - Fix typos in doc 510 | - Start following [SemVer](https://semver.org) properly 511 | 512 | ### Changed 513 | 514 | - Made instructions in the CONTRIBUTING.md file more clear such that it is easier to onboard and there are no conflicts with TUTORIAL.md 515 | 516 | ## [2.5.0] - 2020-01-11 517 | 518 | Added follow and player endpoints 519 | 520 | ## [2.4.4] - 2017-01-04 521 | 522 | Python 3 fix 523 | 524 | ## [2.4.3] - 2017-01-02 525 | 526 | Fixed proxy issue in standard auth flow 527 | 528 | ## [2.4.2] - 2017-01-02 529 | 530 | Support getting audio features for a single track 531 | 532 | ## [2.4.1] - 2017-01-02 533 | 534 | Incorporated proxy support 535 | 536 | ## [2.4.0] - 2016-12-31 537 | 538 | Incorporated a number of PRs 539 | 540 | ## [2.3.8] - 2016-03-31 541 | 542 | Added recs, audio features, user top lists 543 | 544 | ## [2.3.7] - 2015-08-10 545 | 546 | Added current_user_followed_artists 547 | 548 | ## [2.3.6] - 2015-06-03 549 | 550 | Support for offset/limit with album_tracks API 551 | 552 | ## [2.3.5] - 2015-04-28 553 | 554 | Fixed bug in auto retry logic 555 | 556 | ## [2.3.3] - 2015-04-01 557 | 558 | Added client credential flow 559 | 560 | ## [2.3.2] - 2015-03-31 561 | 562 | Added auto retry logic 563 | 564 | ## [2.3.0] - 2015-01-05 565 | 566 | Added session support added by akx. 567 | 568 | ## [2.2.0] - 2014-11-15 569 | 570 | Added support for user_playlist_tracks 571 | 572 | ## [2.1.0] - 2014-10-25 573 | 574 | Added support for new_releases and featured_playlists 575 | 576 | ## [2.0.2] - 2014-08-25 577 | 578 | Moved to spotipy at pypi 579 | 580 | ## [1.2.0] - 2014-08-22 581 | 582 | Upgraded APIs and docs to make it be a real library 583 | 584 | ## [1.310.0] - 2014-08-20 585 | 586 | Added playlist replace and remove methods. Added auth tests. Improved API docs 587 | 588 | ## [1.301.0] - 2014-08-19 589 | 590 | Upgraded version number to take precedence over previously botched release (sigh) 591 | 592 | ## [1.50.0] - 2014-08-14 593 | 594 | Refactored util out of examples and into the main package 595 | 596 | ## [1.49.0] - 2014-07-23 597 | 598 | Support for "Your Music" tracks (add, delete, get), with examples 599 | 600 | ## [1.45.0] - 2014-07-07 601 | 602 | Support for related artists' endpoint. Don't use cache auth codes when scope changes 603 | 604 | ## [1.44.0] - 2014-07-03 605 | 606 | Added show tracks.py example 607 | 608 | ## [1.43.0] - 2014-06-27 609 | 610 | Fixed JSON handling issue 611 | 612 | ## [1.42.0] - 2014-06-19 613 | 614 | Removed dependency on simplejson 615 | 616 | ## [1.40.0] - 2014-06-12 617 | 618 | Initial public release. 619 | 620 | ## [1.4.2] - 2014-06-21 621 | 622 | Added support for retrieving starred playlists 623 | 624 | ## [1.1.0] - 2014-06-17 625 | 626 | Updates to match released API 627 | 628 | ## [1.1.0] - 2014-05-18 629 | 630 | Repackaged for saner imports 631 | 632 | ## [1.0.0] - 2017-04-05 633 | 634 | Initial release 635 | -------------------------------------------------------------------------------- /tests/integration/user_endpoints/test.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | 4 | from spotipy import CLIENT_CREDS_ENV_VARS as CCEV 5 | from spotipy import (Spotify, SpotifyException, SpotifyImplicitGrant, 6 | SpotifyPKCE, prompt_for_user_token) 7 | from tests import helpers 8 | 9 | 10 | class SpotipyPlaylistApiTest(unittest.TestCase): 11 | @classmethod 12 | def setUpClass(cls): 13 | cls.four_tracks = ["spotify:track:6RtPijgfPKROxEzTHNRiDp", 14 | "spotify:track:7IHOIqZUUInxjVkko181PB", 15 | "4VrWlk8IQxevMvERoX08iC", 16 | "http://open.spotify.com/track/3cySlItpiPiIAzU3NyHCJf"] 17 | cls.other_tracks = ["spotify:track:2wySlB6vMzCbQrRnNGOYKa", 18 | "spotify:track:29xKs5BAHlmlX1u4gzQAbJ", 19 | "spotify:track:1PB7gRWcvefzu7t3LJLUlf"] 20 | cls.username = os.getenv(CCEV['client_username']) 21 | 22 | # be wary here, episodes sometimes go away forever 23 | # which could cause tests that rely on four_episodes 24 | # to fail 25 | 26 | cls.four_episodes = [ 27 | "spotify:episode:7f9e73vfXKRqR6uCggK2Xy", 28 | "spotify:episode:4wA1RLFNOWCJ8iprngXmM0", 29 | "spotify:episode:32vhLjJjT7m3f9DFCJUCVZ", 30 | "spotify:episode:7cRcsGYYRUFo1OF3RgRzdx", 31 | ] 32 | 33 | scope = ( 34 | 'playlist-modify-public ' 35 | 'user-library-read ' 36 | 'user-follow-read ' 37 | 'user-library-modify ' 38 | 'user-read-private ' 39 | 'user-top-read ' 40 | 'user-follow-modify ' 41 | 'user-read-recently-played ' 42 | 'ugc-image-upload ' 43 | 'user-read-playback-state' 44 | ) 45 | 46 | token = prompt_for_user_token(cls.username, scope=scope) 47 | 48 | cls.spotify = Spotify(auth=token) 49 | cls.spotify_no_retry = Spotify(auth=token, retries=0) 50 | cls.new_playlist_name = 'spotipy-playlist-test' 51 | cls.new_playlist = helpers.get_spotify_playlist( 52 | cls.spotify, cls.new_playlist_name, cls.username) or \ 53 | cls.spotify.user_playlist_create(cls.username, cls.new_playlist_name) 54 | cls.new_playlist_uri = cls.new_playlist['uri'] 55 | 56 | @classmethod 57 | def tearDownClass(cls): 58 | cls.spotify.current_user_unfollow_playlist(cls.new_playlist['id']) 59 | 60 | def test_user_playlists(self): 61 | playlists = self.spotify.user_playlists(self.username, limit=5) 62 | self.assertTrue('items' in playlists) 63 | self.assertGreaterEqual(len(playlists['items']), 1) 64 | 65 | def test_playlist_items(self): 66 | playlists = self.spotify.user_playlists(self.username, limit=5) 67 | self.assertTrue('items' in playlists) 68 | for playlist in playlists['items']: 69 | if playlist['uri'] != self.new_playlist_uri: 70 | continue 71 | pid = playlist['id'] 72 | results = self.spotify.playlist_items(pid) 73 | self.assertEqual(len(results['items']), 0) 74 | 75 | def test_current_user_playlists(self): 76 | playlists = self.spotify.current_user_playlists(limit=10) 77 | self.assertTrue('items' in playlists) 78 | self.assertGreaterEqual(len(playlists['items']), 1) 79 | self.assertLessEqual(len(playlists['items']), 10) 80 | 81 | def test_current_user_follow_playlist(self): 82 | playlist_to_follow_id = '4erXB04MxwRAVqcUEpu30O' 83 | self.spotify.current_user_follow_playlist(playlist_to_follow_id) 84 | follows = self.spotify.playlist_is_following( 85 | playlist_to_follow_id, [self.username]) 86 | 87 | self.assertTrue(len(follows) == 1, 'proper follows length') 88 | self.assertTrue(follows[0], 'is following') 89 | self.spotify.current_user_unfollow_playlist(playlist_to_follow_id) 90 | 91 | follows = self.spotify.playlist_is_following( 92 | playlist_to_follow_id, [self.username]) 93 | self.assertTrue(len(follows) == 1, 'proper follows length') 94 | self.assertFalse(follows[0], 'is no longer following') 95 | 96 | def test_playlist_replace_items(self): 97 | # add tracks to playlist 98 | self.spotify.playlist_add_items( 99 | self.new_playlist['id'], self.four_tracks) 100 | playlist = self.spotify.playlist(self.new_playlist['id']) 101 | self.assertEqual(playlist['tracks']['total'], 4) 102 | self.assertEqual(len(playlist['tracks']['items']), 4) 103 | 104 | # replace with 3 other tracks 105 | self.spotify.playlist_replace_items(self.new_playlist['id'], 106 | self.other_tracks) 107 | playlist = self.spotify.playlist(self.new_playlist['id']) 108 | self.assertEqual(playlist['tracks']['total'], 3) 109 | self.assertEqual(len(playlist['tracks']['items']), 3) 110 | 111 | self.spotify.playlist_remove_all_occurrences_of_items( 112 | playlist['id'], self.other_tracks) 113 | playlist = self.spotify.playlist(self.new_playlist['id']) 114 | self.assertEqual(playlist["tracks"]["total"], 0) 115 | 116 | def test_get_playlist_by_id(self): 117 | pl = self.spotify.playlist(self.new_playlist['id']) 118 | self.assertEqual(pl["tracks"]["total"], 0) 119 | 120 | def test_max_retries_reached_post(self): 121 | import concurrent.futures 122 | max_workers = 100 123 | total_requests = 500 124 | 125 | def do(): 126 | self.spotify_no_retry.playlist_change_details( 127 | self.new_playlist['id'], description="test") 128 | 129 | with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: 130 | future_to_post = (executor.submit(do) for _i in range(1, total_requests)) 131 | for future in concurrent.futures.as_completed(future_to_post): 132 | try: 133 | future.result() 134 | except Exception as exc: 135 | # Test success 136 | self.assertIsInstance(exc, SpotifyException) 137 | self.assertEqual(exc.http_status, 429) 138 | return 139 | 140 | self.fail() 141 | 142 | def test_playlist_add_items(self): 143 | # add tracks to playlist 144 | self.spotify.playlist_add_items( 145 | self.new_playlist['id'], self.other_tracks) 146 | playlist = self.spotify.playlist_items(self.new_playlist['id']) 147 | self.assertEqual(playlist['total'], 3) 148 | self.assertEqual(len(playlist['items']), 3) 149 | 150 | pl = self.spotify.playlist_items(self.new_playlist['id'], limit=2) 151 | self.assertEqual(len(pl["items"]), 2) 152 | 153 | self.spotify.playlist_remove_all_occurrences_of_items( 154 | self.new_playlist['id'], self.other_tracks) 155 | playlist = self.spotify.playlist_items(self.new_playlist['id']) 156 | self.assertEqual(playlist["total"], 0) 157 | 158 | def test_playlist_add_episodes(self): 159 | # add episodes to playlist 160 | self.spotify.playlist_add_items( 161 | self.new_playlist['id'], self.four_episodes) 162 | playlist = self.spotify.playlist_items(self.new_playlist['id']) 163 | self.assertEqual(playlist['total'], 4) 164 | self.assertEqual(len(playlist['items']), 4) 165 | 166 | pl = self.spotify.playlist_items(self.new_playlist['id'], limit=2) 167 | self.assertEqual(len(pl["items"]), 2) 168 | 169 | self.spotify.playlist_remove_all_occurrences_of_items( 170 | self.new_playlist['id'], self.four_episodes) 171 | playlist = self.spotify.playlist_items(self.new_playlist['id']) 172 | self.assertEqual(playlist["total"], 0) 173 | 174 | def test_playlist_cover_image(self): 175 | # From https://dog.ceo/api/breeds/image/random 176 | small_image = "https://images.dog.ceo/breeds/poodle-toy/n02113624_8936.jpg" 177 | dog_base64 = helpers.get_as_base64(small_image) 178 | self.spotify.playlist_upload_cover_image(self.new_playlist_uri, dog_base64) 179 | 180 | res = self.spotify.playlist_cover_image(self.new_playlist_uri) 181 | self.assertEqual(len(res), 1) 182 | first_image = res[0] 183 | self.assertIn('width', first_image) 184 | self.assertIn('height', first_image) 185 | self.assertIn('url', first_image) 186 | 187 | def test_large_playlist_cover_image(self): 188 | # From https://dog.ceo/api/breeds/image/random 189 | large_image = "https://images.dog.ceo/breeds/pointer-germanlonghair/hans2.jpg" 190 | dog_base64 = helpers.get_as_base64(large_image) 191 | try: 192 | self.spotify.playlist_upload_cover_image(self.new_playlist_uri, dog_base64) 193 | except Exception as e: 194 | self.assertIsInstance(e, SpotifyException) 195 | self.assertEqual(e.http_status, 413) 196 | return 197 | self.fail() 198 | 199 | def test_deprecated_starred(self): 200 | pl = self.spotify.user_playlist(self.username) 201 | self.assertTrue(pl["tracks"] is None) 202 | self.assertTrue(pl["owner"] is None) 203 | 204 | def test_deprecated_user_playlist(self): 205 | # Test without user due to change from 206 | # https://developer.spotify.com/community/news/2018/06/12/changes-to-playlist-uris/ 207 | pl = self.spotify.user_playlist(None, self.new_playlist['id']) 208 | self.assertEqual(pl["tracks"]["total"], 0) 209 | 210 | 211 | class SpotipyLibraryApiTests(unittest.TestCase): 212 | @classmethod 213 | def setUpClass(cls): 214 | cls.four_tracks = ["spotify:track:6RtPijgfPKROxEzTHNRiDp", 215 | "spotify:track:7IHOIqZUUInxjVkko181PB", 216 | "4VrWlk8IQxevMvERoX08iC", 217 | "http://open.spotify.com/track/3cySlItpiPiIAzU3NyHCJf"] 218 | cls.album_ids = ["spotify:album:6kL09DaURb7rAoqqaA51KU", 219 | "spotify:album:6RTzC0rDbvagTSJLlY7AKl"] 220 | cls.episode_ids = [ 221 | "spotify:episode:3OEdPEYB69pfXoBrhvQYeC", 222 | "spotify:episode:5LEFdZ9pYh99wSz7Go2D0g" 223 | ] 224 | cls.username = os.getenv(CCEV['client_username']) 225 | 226 | scope = ( 227 | 'playlist-modify-public ' 228 | 'user-library-read ' 229 | 'user-follow-read ' 230 | 'user-library-modify ' 231 | 'user-read-private ' 232 | 'user-top-read ' 233 | 'user-follow-modify ' 234 | 'user-read-recently-played ' 235 | 'ugc-image-upload ' 236 | 'user-read-playback-state' 237 | ) 238 | 239 | token = prompt_for_user_token(cls.username, scope=scope) 240 | 241 | cls.spotify = Spotify(auth=token) 242 | 243 | def test_track_bad_id(self): 244 | with self.assertRaises(SpotifyException): 245 | self.spotify.track('BadID123') 246 | 247 | def test_current_user_saved_tracks(self): 248 | tracks = self.spotify.current_user_saved_tracks() 249 | self.assertGreaterEqual(len(tracks['items']), 0) 250 | 251 | def test_current_user_save_tracks(self): 252 | tracks = self.spotify.current_user_saved_tracks() 253 | total = tracks['total'] 254 | self.spotify.current_user_saved_tracks_add(self.four_tracks) 255 | 256 | tracks = self.spotify.current_user_saved_tracks() 257 | new_total = tracks['total'] 258 | self.assertEqual(new_total - total, len(self.four_tracks)) 259 | 260 | self.spotify.current_user_saved_tracks_delete( 261 | self.four_tracks) 262 | tracks = self.spotify.current_user_saved_tracks() 263 | new_total = tracks['total'] 264 | 265 | def test_current_user_unsave_tracks(self): 266 | tracks = self.spotify.current_user_saved_tracks() 267 | total = tracks['total'] 268 | self.spotify.current_user_saved_tracks_add(self.four_tracks) 269 | 270 | tracks = self.spotify.current_user_saved_tracks() 271 | new_total = tracks['total'] 272 | 273 | self.spotify.current_user_saved_tracks_delete( 274 | self.four_tracks) 275 | tracks = self.spotify.current_user_saved_tracks() 276 | new_total = tracks['total'] 277 | self.assertEqual(new_total, total) 278 | 279 | def test_current_user_saved_albums(self): 280 | # Add 281 | self.spotify.current_user_saved_albums_add(self.album_ids) 282 | albums = self.spotify.current_user_saved_albums() 283 | self.assertGreaterEqual(len(albums['items']), 2) 284 | 285 | # Contains 286 | resp = self.spotify.current_user_saved_albums_contains(self.album_ids) 287 | self.assertEqual(resp, [True, True]) 288 | 289 | # Remove 290 | self.spotify.current_user_saved_albums_delete(self.album_ids) 291 | resp = self.spotify.current_user_saved_albums_contains(self.album_ids) 292 | self.assertEqual(resp, [False, False]) 293 | 294 | def test_current_user_saved_episodes(self): 295 | # Add 296 | self.spotify.current_user_saved_episodes_add(self.episode_ids) 297 | episodes = self.spotify.current_user_saved_episodes(market="US") 298 | self.assertGreaterEqual(len(episodes['items']), 2) 299 | 300 | # Contains 301 | resp = self.spotify.current_user_saved_episodes_contains(self.episode_ids) 302 | self.assertEqual(resp, [True, True]) 303 | 304 | # Remove 305 | self.spotify.current_user_saved_episodes_delete(self.episode_ids) 306 | resp = self.spotify.current_user_saved_episodes_contains(self.episode_ids) 307 | self.assertEqual(resp, [False, False]) 308 | 309 | 310 | class SpotipyUserApiTests(unittest.TestCase): 311 | @classmethod 312 | def setUpClass(cls): 313 | cls.username = os.getenv(CCEV['client_username']) 314 | 315 | scope = ( 316 | 'playlist-modify-public ' 317 | 'user-library-read ' 318 | 'user-follow-read ' 319 | 'user-library-modify ' 320 | 'user-read-private ' 321 | 'user-top-read ' 322 | 'user-follow-modify ' 323 | 'user-read-recently-played ' 324 | 'ugc-image-upload ' 325 | 'user-read-playback-state' 326 | ) 327 | 328 | token = prompt_for_user_token(cls.username, scope=scope) 329 | 330 | cls.spotify = Spotify(auth=token) 331 | 332 | def test_basic_user_profile(self): 333 | user = self.spotify.user(self.username) 334 | self.assertEqual(user['id'], self.username.lower()) 335 | 336 | def test_current_user(self): 337 | user = self.spotify.current_user() 338 | self.assertEqual(user['id'], self.username.lower()) 339 | 340 | def test_me(self): 341 | user = self.spotify.me() 342 | self.assertTrue(user['id'] == self.username.lower()) 343 | 344 | def test_current_user_top_tracks(self): 345 | response = self.spotify.current_user_top_tracks() 346 | items = response['items'] 347 | self.assertGreaterEqual(len(items), 0) 348 | 349 | def test_current_user_top_artists(self): 350 | response = self.spotify.current_user_top_artists() 351 | items = response['items'] 352 | self.assertGreaterEqual(len(items), 0) 353 | 354 | 355 | class SpotipyBrowseApiTests(unittest.TestCase): 356 | @classmethod 357 | def setUpClass(cls): 358 | username = os.getenv(CCEV['client_username']) 359 | token = prompt_for_user_token(username) 360 | cls.spotify = Spotify(auth=token) 361 | 362 | def test_category(self): 363 | rock_cat_id = '0JQ5DAqbMKFDXXwE9BDJAr' 364 | response = self.spotify.category(rock_cat_id) 365 | self.assertEqual(response['name'], 'Rock') 366 | 367 | def test_categories(self): 368 | response = self.spotify.categories() 369 | self.assertGreater(len(response['categories']), 0) 370 | 371 | def test_categories_country(self): 372 | response = self.spotify.categories(country='US') 373 | self.assertGreater(len(response['categories']), 0) 374 | 375 | def test_categories_global(self): 376 | response = self.spotify.categories() 377 | self.assertGreater(len(response['categories']), 0) 378 | 379 | def test_categories_locale(self): 380 | response = self.spotify.categories(locale='en_US') 381 | self.assertGreater(len(response['categories']), 0) 382 | 383 | def test_categories_limit_low(self): 384 | response = self.spotify.categories(limit=1) 385 | self.assertEqual(len(response['categories']['items']), 1) 386 | 387 | def test_categories_limit_high(self): 388 | response = self.spotify.categories(limit=50) 389 | self.assertLessEqual(len(response['categories']['items']), 50) 390 | 391 | def test_new_releases(self): 392 | response = self.spotify.new_releases() 393 | self.assertGreater(len(response['albums']['items']), 0) 394 | 395 | def test_new_releases_limit_low(self): 396 | response = self.spotify.new_releases(limit=1) 397 | self.assertEqual(len(response['albums']['items']), 1) 398 | 399 | def test_new_releases_limit_high(self): 400 | response = self.spotify.new_releases(limit=50) 401 | self.assertLessEqual(len(response['albums']['items']), 50) 402 | 403 | 404 | class SpotipyFollowApiTests(unittest.TestCase): 405 | @classmethod 406 | def setUpClass(cls): 407 | cls.username = os.getenv(CCEV['client_username']) 408 | 409 | scope = ( 410 | 'playlist-modify-public ' 411 | 'user-library-read ' 412 | 'user-follow-read ' 413 | 'user-library-modify ' 414 | 'user-read-private ' 415 | 'user-top-read ' 416 | 'user-follow-modify ' 417 | 'user-read-recently-played ' 418 | 'ugc-image-upload ' 419 | 'user-read-playback-state' 420 | ) 421 | 422 | token = prompt_for_user_token(cls.username, scope=scope) 423 | 424 | cls.spotify = Spotify(auth=token) 425 | 426 | def test_current_user_follows(self): 427 | response = self.spotify.current_user_followed_artists() 428 | artists = response['artists'] 429 | self.assertGreaterEqual(len(artists['items']), 0) 430 | 431 | def test_user_follows_and_unfollows_artist(self): 432 | # Initially follows 1 artist 433 | current_user_followed_artists = self.spotify.current_user_followed_artists()[ 434 | 'artists']['total'] 435 | 436 | # Follow 2 more artists 437 | artists = ["6DPYiyq5kWVQS4RGwxzPC7", "0NbfKEOTQCcwd6o7wSDOHI"] 438 | self.spotify.user_follow_artists(artists) 439 | self.assertTrue(all(self.spotify.current_user_following_artists(artists))) 440 | 441 | # Unfollow these 2 artists 442 | self.spotify.user_unfollow_artists(artists) 443 | self.assertFalse(any(self.spotify.current_user_following_artists(artists))) 444 | res = self.spotify.current_user_followed_artists() 445 | self.assertEqual(res['artists']['total'], current_user_followed_artists) 446 | 447 | def test_user_follows_and_unfollows_user(self): 448 | users = ["11111204", "xlqeojt6n7on0j7coh9go8ifd"] 449 | 450 | # Follow 2 more users 451 | self.spotify.user_follow_users(users) 452 | self.assertTrue(all(self.spotify.current_user_following_users(users))) 453 | 454 | # Unfollow these 2 users 455 | self.spotify.user_unfollow_users(users) 456 | self.assertFalse(any(self.spotify.current_user_following_users(users))) 457 | 458 | 459 | class SpotipyPlayerApiTests(unittest.TestCase): 460 | @classmethod 461 | def setUpClass(cls): 462 | cls.username = os.getenv(CCEV['client_username']) 463 | 464 | scope = ( 465 | 'playlist-modify-public ' 466 | 'user-library-read ' 467 | 'user-follow-read ' 468 | 'user-library-modify ' 469 | 'user-read-private ' 470 | 'user-top-read ' 471 | 'user-follow-modify ' 472 | 'user-read-recently-played ' 473 | 'ugc-image-upload ' 474 | 'user-read-playback-state' 475 | ) 476 | 477 | token = prompt_for_user_token(cls.username, scope=scope) 478 | 479 | cls.spotify = Spotify(auth=token) 480 | 481 | def test_devices(self): 482 | # No devices playing by default 483 | res = self.spotify.devices() 484 | self.assertGreaterEqual(len(res["devices"]), 0) 485 | 486 | def test_current_user_recently_played(self): 487 | # No cursor 488 | res = self.spotify.current_user_recently_played() 489 | self.assertLessEqual(len(res['items']), 50) 490 | # not much more to test if account is inactive and has no recently played tracks 491 | 492 | 493 | class SpotipyImplicitGrantTests(unittest.TestCase): 494 | @classmethod 495 | def setUpClass(cls): 496 | scope = ( 497 | 'user-follow-read ' 498 | 'user-follow-modify ' 499 | ) 500 | auth_manager = SpotifyImplicitGrant(scope=scope, 501 | cache_path=".cache-implicittest") 502 | cls.spotify = Spotify(auth_manager=auth_manager) 503 | 504 | def test_current_user(self): 505 | c_user = self.spotify.current_user() 506 | user = self.spotify.user(c_user['id']) 507 | self.assertEqual(c_user['display_name'], user['display_name']) 508 | 509 | 510 | class SpotifyPKCETests(unittest.TestCase): 511 | 512 | @classmethod 513 | def setUpClass(cls): 514 | scope = ( 515 | 'user-follow-read ' 516 | 'user-follow-modify ' 517 | ) 518 | auth_manager = SpotifyPKCE(scope=scope, cache_path=".cache-pkcetest") 519 | cls.spotify = Spotify(auth_manager=auth_manager) 520 | 521 | def test_current_user(self): 522 | c_user = self.spotify.current_user() 523 | user = self.spotify.user(c_user['id']) 524 | self.assertEqual(c_user['display_name'], user['display_name']) 525 | 526 | 527 | class SpotifyQueueApiTests(unittest.TestCase): 528 | 529 | @classmethod 530 | def setUp(self): 531 | self.spotify = Spotify(auth="test_token") 532 | 533 | def test_get_queue(self, mock_get): 534 | # Mock the response from _get 535 | mock_get.return_value = {'songs': ['song1', 'song2']} 536 | 537 | # Call the queue function 538 | response = self.spotify.queue() 539 | 540 | # Check if the correct endpoint is called 541 | mock_get.assert_called_with("me/player/queue") 542 | 543 | # Check if the response is as expected 544 | self.assertEqual(response, {'songs': ['song1', 'song2']}) 545 | 546 | def test_add_to_queue(self, mock_post): 547 | test_uri = 'spotify:track:123' 548 | 549 | # Call the add_to_queue function 550 | self.spotify.add_to_queue(test_uri) 551 | 552 | # Check if the correct endpoint is called 553 | endpoint = f"me/player/queue?uri={test_uri}" 554 | mock_post.assert_called_with(endpoint) 555 | 556 | def test_add_to_queue_with_device_id(self, mock_post): 557 | test_uri = 'spotify:track:123' 558 | device_id = 'device123' 559 | 560 | # Call the add_to_queue function with a device_id 561 | self.spotify.add_to_queue(test_uri, device_id=device_id) 562 | 563 | # Check if the correct endpoint is called 564 | endpoint = f"me/player/queue?uri={test_uri}&device_id={device_id}" 565 | mock_post.assert_called_with(endpoint) 566 | -------------------------------------------------------------------------------- /tests/unit/test_oauth.py: -------------------------------------------------------------------------------- 1 | import io 2 | import json 3 | import unittest 4 | import unittest.mock as mock 5 | import urllib.parse as urllibparse 6 | 7 | from spotipy import SpotifyImplicitGrant, SpotifyOAuth, SpotifyPKCE 8 | from spotipy.cache_handler import MemoryCacheHandler 9 | from spotipy.oauth2 import (SpotifyClientCredentials, SpotifyOauthError, 10 | SpotifyStateError) 11 | 12 | patch = mock.patch 13 | DEFAULT = mock.DEFAULT 14 | 15 | 16 | def _make_fake_token(expires_at, expires_in, scope): 17 | return dict( 18 | expires_at=expires_at, 19 | expires_in=expires_in, 20 | scope=scope, 21 | token_type="Bearer", 22 | refresh_token="REFRESH", 23 | access_token="ACCESS") 24 | 25 | 26 | def _fake_file(): 27 | return mock.Mock(spec_set=io.FileIO) 28 | 29 | 30 | def _token_file(token): 31 | fi = _fake_file() 32 | fi.read.return_value = token 33 | return fi 34 | 35 | 36 | def _make_oauth(*args, **kwargs): 37 | return SpotifyOAuth("CLID", "CLISEC", "REDIR", "STATE", *args, **kwargs) 38 | 39 | 40 | def _make_implicitgrantauth(*args, **kwargs): 41 | return SpotifyImplicitGrant("CLID", "REDIR", "STATE", *args, **kwargs) 42 | 43 | 44 | def _make_pkceauth(*args, **kwargs): 45 | return SpotifyPKCE("CLID", "REDIR", "STATE", *args, **kwargs) 46 | 47 | 48 | class OAuthCacheTest(unittest.TestCase): 49 | 50 | @patch.multiple(SpotifyOAuth, 51 | is_token_expired=DEFAULT, refresh_access_token=DEFAULT) 52 | @patch('spotipy.cache_handler.open', create=True) 53 | def test_gets_from_cache_path(self, opener, 54 | is_token_expired, refresh_access_token): 55 | """Test that the token is retrieved from the cache path.""" 56 | scope = "playlist-modify-private" 57 | path = ".cache-username" 58 | tok = _make_fake_token(1, 1, scope) 59 | token_file = _token_file(json.dumps(tok, ensure_ascii=False)) 60 | opener.return_value = token_file 61 | opener.return_value.__enter__ = mock.Mock(return_value=token_file) 62 | opener.return_value.__exit__ = mock.Mock(return_value=False) 63 | is_token_expired.return_value = False 64 | 65 | spot = _make_oauth(scope, path) 66 | cached_tok = spot.validate_token(spot.cache_handler.get_cached_token()) 67 | cached_tok_legacy = spot.get_cached_token() 68 | 69 | opener.assert_called_with(path, encoding='utf-8') 70 | self.assertIsNotNone(cached_tok) 71 | self.assertIsNotNone(cached_tok_legacy) 72 | self.assertEqual(refresh_access_token.call_count, 0) 73 | 74 | @patch.multiple(SpotifyOAuth, 75 | is_token_expired=DEFAULT, refresh_access_token=DEFAULT) 76 | @patch('spotipy.cache_handler.open', create=True) 77 | def test_expired_token_refreshes(self, opener, 78 | is_token_expired, refresh_access_token): 79 | """Test that an expired token is refreshed.""" 80 | scope = "playlist-modify-private" 81 | path = ".cache-username" 82 | expired_tok = _make_fake_token(0, None, scope) 83 | fresh_tok = _make_fake_token(1, 1, scope) 84 | 85 | token_file = _token_file(json.dumps(expired_tok, ensure_ascii=False)) 86 | opener.return_value.__enter__ = mock.Mock(return_value=token_file) 87 | opener.return_value.__exit__ = mock.Mock(return_value=False) 88 | refresh_access_token.return_value = fresh_tok 89 | 90 | spot = _make_oauth(scope, path) 91 | spot.validate_token(spot.cache_handler.get_cached_token()) 92 | 93 | is_token_expired.assert_called_with(expired_tok) 94 | refresh_access_token.assert_called_with(expired_tok['refresh_token']) 95 | opener.assert_any_call(path, encoding='utf-8') 96 | 97 | @patch.multiple(SpotifyOAuth, 98 | is_token_expired=DEFAULT, refresh_access_token=DEFAULT) 99 | @patch('spotipy.cache_handler.open', create=True) 100 | def test_badly_scoped_token_bails(self, opener, 101 | is_token_expired, refresh_access_token): 102 | token_scope = "playlist-modify-public" 103 | requested_scope = "playlist-modify-private" 104 | path = ".cache-username" 105 | tok = _make_fake_token(1, 1, token_scope) 106 | 107 | token_file = _token_file(json.dumps(tok, ensure_ascii=False)) 108 | opener.return_value = token_file 109 | opener.return_value.__enter__ = mock.Mock(return_value=token_file) 110 | opener.return_value.__exit__ = mock.Mock(return_value=False) 111 | is_token_expired.return_value = False 112 | 113 | spot = _make_oauth(requested_scope, path) 114 | cached_tok = spot.validate_token(spot.cache_handler.get_cached_token()) 115 | 116 | opener.assert_called_with(path, encoding='utf-8') 117 | self.assertIsNone(cached_tok) 118 | self.assertEqual(refresh_access_token.call_count, 0) 119 | 120 | @patch('spotipy.cache_handler.open', create=True) 121 | def test_saves_to_cache_path(self, opener): 122 | """Test that the token is saved to the cache path.""" 123 | scope = "playlist-modify-private" 124 | path = ".cache-username" 125 | tok = _make_fake_token(1, 1, scope) 126 | 127 | fi = _fake_file() 128 | opener.return_value = fi 129 | opener.return_value.__enter__ = mock.Mock(return_value=fi) 130 | opener.return_value.__exit__ = mock.Mock(return_value=False) 131 | 132 | spot = SpotifyOAuth("CLID", "CLISEC", "REDIR", "STATE", scope, path) 133 | spot.cache_handler.save_token_to_cache(tok) 134 | 135 | opener.assert_called_with(path, 'w', encoding='utf-8') 136 | self.assertTrue(fi.write.called) 137 | 138 | @patch('spotipy.cache_handler.open', create=True) 139 | def test_saves_to_cache_path_legacy(self, opener): 140 | scope = "playlist-modify-private" 141 | path = ".cache-username" 142 | tok = _make_fake_token(1, 1, scope) 143 | 144 | fi = _fake_file() 145 | opener.return_value = fi 146 | opener.return_value.__enter__ = mock.Mock(return_value=fi) 147 | opener.return_value.__exit__ = mock.Mock(return_value=False) 148 | 149 | spot = SpotifyOAuth("CLID", "CLISEC", "REDIR", "STATE", scope, path) 150 | spot._save_token_info(tok) 151 | 152 | opener.assert_called_with(path, 'w', encoding='utf-8') 153 | self.assertTrue(fi.write.called) 154 | 155 | def test_cache_handler(self): 156 | scope = "playlist-modify-private" 157 | tok = _make_fake_token(1, 1, scope) 158 | 159 | spot = _make_oauth(scope, cache_handler=MemoryCacheHandler()) 160 | spot.cache_handler.save_token_to_cache(tok) 161 | cached_tok = spot.cache_handler.get_cached_token() 162 | 163 | self.assertEqual(tok, cached_tok) 164 | 165 | 166 | class TestSpotifyOAuthGetAuthorizeUrl(unittest.TestCase): 167 | 168 | def test_get_authorize_url_doesnt_pass_state_by_default(self): 169 | oauth = SpotifyOAuth("CLID", "CLISEC", "REDIR") 170 | 171 | url = oauth.get_authorize_url() 172 | 173 | parsed_url = urllibparse.urlparse(url) 174 | parsed_qs = urllibparse.parse_qs(parsed_url.query) 175 | self.assertNotIn('state', parsed_qs) 176 | 177 | def test_get_authorize_url_passes_state_from_constructor(self): 178 | state = "STATE" 179 | oauth = SpotifyOAuth("CLID", "CLISEC", "REDIR", state) 180 | 181 | url = oauth.get_authorize_url() 182 | 183 | parsed_url = urllibparse.urlparse(url) 184 | parsed_qs = urllibparse.parse_qs(parsed_url.query) 185 | self.assertEqual(parsed_qs['state'][0], state) 186 | 187 | def test_get_authorize_url_passes_state_from_func_call(self): 188 | state = "STATE" 189 | oauth = SpotifyOAuth("CLID", "CLISEC", "REDIR", "NOT STATE") 190 | 191 | url = oauth.get_authorize_url(state=state) 192 | 193 | parsed_url = urllibparse.urlparse(url) 194 | parsed_qs = urllibparse.parse_qs(parsed_url.query) 195 | self.assertEqual(parsed_qs['state'][0], state) 196 | 197 | def test_get_authorize_url_does_not_show_dialog_by_default(self): 198 | oauth = SpotifyOAuth("CLID", "CLISEC", "REDIR") 199 | 200 | url = oauth.get_authorize_url() 201 | 202 | parsed_url = urllibparse.urlparse(url) 203 | parsed_qs = urllibparse.parse_qs(parsed_url.query) 204 | self.assertNotIn('show_dialog', parsed_qs) 205 | 206 | def test_get_authorize_url_shows_dialog_when_requested(self): 207 | oauth = SpotifyOAuth("CLID", "CLISEC", "REDIR", show_dialog=True) 208 | 209 | url = oauth.get_authorize_url() 210 | 211 | parsed_url = urllibparse.urlparse(url) 212 | parsed_qs = urllibparse.parse_qs(parsed_url.query) 213 | self.assertTrue(parsed_qs['show_dialog']) 214 | 215 | 216 | class TestSpotifyOAuthGetAuthResponseInteractive(unittest.TestCase): 217 | 218 | @patch('spotipy.oauth2.webbrowser') 219 | @patch( 220 | 'spotipy.oauth2.SpotifyOAuth._get_user_input', 221 | return_value="redir.io?code=abcde" 222 | ) 223 | def test_get_auth_response_without_state(self, webbrowser_mock, get_user_input_mock): 224 | oauth = SpotifyOAuth("CLID", "CLISEC", "redir.io") 225 | code = oauth.get_auth_response() 226 | self.assertEqual(code, "abcde") 227 | 228 | @patch('spotipy.oauth2.webbrowser') 229 | @patch( 230 | 'spotipy.oauth2.SpotifyOAuth._get_user_input', 231 | return_value="redir.io?code=abcde&state=wxyz" 232 | ) 233 | def test_get_auth_response_with_consistent_state(self, webbrowser_mock, get_user_input_mock): 234 | oauth = SpotifyOAuth("CLID", "CLISEC", "redir.io", state='wxyz') 235 | code = oauth.get_auth_response() 236 | self.assertEqual(code, "abcde") 237 | 238 | @patch('spotipy.oauth2.webbrowser') 239 | @patch( 240 | 'spotipy.oauth2.SpotifyOAuth._get_user_input', 241 | return_value="redir.io?code=abcde&state=someotherstate" 242 | ) 243 | def test_get_auth_response_with_inconsistent_state(self, webbrowser_mock, get_user_input_mock): 244 | oauth = SpotifyOAuth("CLID", "CLISEC", "redir.io", state='wxyz') 245 | 246 | with self.assertRaises(SpotifyStateError): 247 | oauth.get_auth_response() 248 | 249 | 250 | class TestSpotifyClientCredentials(unittest.TestCase): 251 | 252 | def test_spotify_client_credentials_get_access_token(self): 253 | oauth = SpotifyClientCredentials(client_id='ID', client_secret='SECRET') 254 | with self.assertRaises(SpotifyOauthError) as error: 255 | oauth.get_access_token(check_cache=False) 256 | self.assertEqual(error.exception.error, 'invalid_client') 257 | 258 | 259 | class ImplicitGrantCacheTest(unittest.TestCase): 260 | 261 | @patch.object(SpotifyImplicitGrant, "is_token_expired", DEFAULT) 262 | @patch('spotipy.cache_handler.open', create=True) 263 | def test_gets_from_cache_path(self, opener, is_token_expired): 264 | scope = "playlist-modify-private" 265 | path = ".cache-username" 266 | tok = _make_fake_token(1, 1, scope) 267 | 268 | token_file = _token_file(json.dumps(tok, ensure_ascii=False)) 269 | opener.return_value = token_file 270 | opener.return_value.__enter__ = mock.Mock(return_value=token_file) 271 | opener.return_value.__exit__ = mock.Mock(return_value=False) 272 | is_token_expired.return_value = False 273 | 274 | spot = _make_implicitgrantauth(scope, path) 275 | cached_tok = spot.cache_handler.get_cached_token() 276 | cached_tok_legacy = spot.get_cached_token() 277 | 278 | opener.assert_called_with(path, encoding='utf-8') 279 | self.assertIsNotNone(cached_tok) 280 | self.assertIsNotNone(cached_tok_legacy) 281 | 282 | @patch.object(SpotifyImplicitGrant, "is_token_expired", DEFAULT) 283 | @patch('spotipy.cache_handler.open', create=True) 284 | def test_expired_token_returns_none(self, opener, is_token_expired): 285 | """Test that an expired token returns None.""" 286 | scope = "playlist-modify-private" 287 | path = ".cache-username" 288 | expired_tok = _make_fake_token(0, None, scope) 289 | 290 | token_file = _token_file(json.dumps(expired_tok, ensure_ascii=False)) 291 | opener.return_value = token_file 292 | opener.return_value.__enter__ = mock.Mock(return_value=token_file) 293 | opener.return_value.__exit__ = mock.Mock(return_value=False) 294 | 295 | spot = _make_implicitgrantauth(scope, path) 296 | cached_tok = spot.validate_token(spot.cache_handler.get_cached_token()) 297 | 298 | is_token_expired.assert_called_with(expired_tok) 299 | opener.assert_any_call(path, encoding='utf-8') 300 | self.assertIsNone(cached_tok) 301 | 302 | @patch.object(SpotifyImplicitGrant, "is_token_expired", DEFAULT) 303 | @patch('spotipy.cache_handler.open', create=True) 304 | def test_badly_scoped_token_bails(self, opener, is_token_expired): 305 | token_scope = "playlist-modify-public" 306 | requested_scope = "playlist-modify-private" 307 | path = ".cache-username" 308 | tok = _make_fake_token(1, 1, token_scope) 309 | 310 | token_file = _token_file(json.dumps(tok, ensure_ascii=False)) 311 | opener.return_value = token_file 312 | opener.return_value.__enter__ = mock.Mock(return_value=token_file) 313 | opener.return_value.__exit__ = mock.Mock(return_value=False) 314 | is_token_expired.return_value = False 315 | 316 | spot = _make_implicitgrantauth(requested_scope, path) 317 | cached_tok = spot.validate_token(spot.cache_handler.get_cached_token()) 318 | 319 | opener.assert_called_with(path, encoding='utf-8') 320 | self.assertIsNone(cached_tok) 321 | 322 | @patch('spotipy.cache_handler.open', create=True) 323 | def test_saves_to_cache_path(self, opener): 324 | scope = "playlist-modify-private" 325 | path = ".cache-username" 326 | tok = _make_fake_token(1, 1, scope) 327 | 328 | fi = _fake_file() 329 | opener.return_value = fi 330 | 331 | opener.return_value.__enter__ = mock.Mock(return_value=fi) 332 | opener.return_value.__exit__ = mock.Mock(return_value=False) 333 | spot = SpotifyImplicitGrant("CLID", "REDIR", "STATE", scope, path) 334 | spot.cache_handler.save_token_to_cache(tok) 335 | 336 | opener.assert_called_with(path, 'w', encoding='utf-8') 337 | self.assertTrue(fi.write.called) 338 | 339 | @patch('spotipy.cache_handler.open', create=True) 340 | def test_saves_to_cache_path_legacy(self, opener): 341 | scope = "playlist-modify-private" 342 | path = ".cache-username" 343 | tok = _make_fake_token(1, 1, scope) 344 | 345 | fi = _fake_file() 346 | opener.return_value = fi 347 | opener.return_value.__enter__ = mock.Mock(return_value=fi) 348 | opener.return_value.__exit__ = mock.Mock(return_value=False) 349 | 350 | spot = SpotifyImplicitGrant("CLID", "REDIR", "STATE", scope, path) 351 | spot._save_token_info(tok) 352 | 353 | opener.assert_called_with(path, 'w', encoding='utf-8') 354 | self.assertTrue(fi.write.called) 355 | 356 | 357 | class TestSpotifyImplicitGrant(unittest.TestCase): 358 | 359 | def test_get_authorize_url_doesnt_pass_state_by_default(self): 360 | auth = SpotifyImplicitGrant("CLID", "REDIR") 361 | 362 | url = auth.get_authorize_url() 363 | 364 | parsed_url = urllibparse.urlparse(url) 365 | parsed_qs = urllibparse.parse_qs(parsed_url.query) 366 | self.assertNotIn('state', parsed_qs) 367 | 368 | def test_get_authorize_url_passes_state_from_constructor(self): 369 | state = "STATE" 370 | auth = SpotifyImplicitGrant("CLID", "REDIR", state) 371 | 372 | url = auth.get_authorize_url() 373 | 374 | parsed_url = urllibparse.urlparse(url) 375 | parsed_qs = urllibparse.parse_qs(parsed_url.query) 376 | self.assertEqual(parsed_qs['state'][0], state) 377 | 378 | def test_get_authorize_url_passes_state_from_func_call(self): 379 | state = "STATE" 380 | auth = SpotifyImplicitGrant("CLID", "REDIR", "NOT STATE") 381 | 382 | url = auth.get_authorize_url(state=state) 383 | 384 | parsed_url = urllibparse.urlparse(url) 385 | parsed_qs = urllibparse.parse_qs(parsed_url.query) 386 | self.assertEqual(parsed_qs['state'][0], state) 387 | 388 | def test_get_authorize_url_does_not_show_dialog_by_default(self): 389 | auth = SpotifyImplicitGrant("CLID", "REDIR") 390 | 391 | url = auth.get_authorize_url() 392 | 393 | parsed_url = urllibparse.urlparse(url) 394 | parsed_qs = urllibparse.parse_qs(parsed_url.query) 395 | self.assertNotIn('show_dialog', parsed_qs) 396 | 397 | def test_get_authorize_url_shows_dialog_when_requested(self): 398 | auth = SpotifyImplicitGrant("CLID", "REDIR", show_dialog=True) 399 | 400 | url = auth.get_authorize_url() 401 | 402 | parsed_url = urllibparse.urlparse(url) 403 | parsed_qs = urllibparse.parse_qs(parsed_url.query) 404 | self.assertTrue(parsed_qs['show_dialog']) 405 | 406 | 407 | class SpotifyPKCECacheTest(unittest.TestCase): 408 | 409 | @patch.multiple(SpotifyPKCE, 410 | is_token_expired=DEFAULT, refresh_access_token=DEFAULT) 411 | @patch('spotipy.cache_handler.open', create=True) 412 | def test_gets_from_cache_path(self, opener, 413 | is_token_expired, refresh_access_token): 414 | scope = "playlist-modify-private" 415 | path = ".cache-username" 416 | tok = _make_fake_token(1, 1, scope) 417 | 418 | token_file = _token_file(json.dumps(tok, ensure_ascii=False)) 419 | opener.return_value = token_file 420 | opener.return_value.__enter__ = mock.Mock(return_value=token_file) 421 | opener.return_value.__exit__ = mock.Mock(return_value=False) 422 | is_token_expired.return_value = False 423 | 424 | spot = _make_pkceauth(scope, path) 425 | cached_tok = spot.cache_handler.get_cached_token() 426 | cached_tok_legacy = spot.get_cached_token() 427 | 428 | opener.assert_called_with(path, encoding='utf-8') 429 | self.assertIsNotNone(cached_tok) 430 | self.assertIsNotNone(cached_tok_legacy) 431 | self.assertEqual(refresh_access_token.call_count, 0) 432 | 433 | @patch.multiple(SpotifyPKCE, 434 | is_token_expired=DEFAULT, refresh_access_token=DEFAULT) 435 | @patch('spotipy.cache_handler.open', create=True) 436 | def test_expired_token_refreshes(self, opener, 437 | is_token_expired, refresh_access_token): 438 | scope = "playlist-modify-private" 439 | path = ".cache-username" 440 | expired_tok = _make_fake_token(0, None, scope) 441 | fresh_tok = _make_fake_token(1, 1, scope) 442 | 443 | token_file = _token_file(json.dumps(expired_tok, ensure_ascii=False)) 444 | opener.return_value.__enter__ = mock.Mock(return_value=token_file) 445 | opener.return_value.__exit__ = mock.Mock(return_value=False) 446 | refresh_access_token.return_value = fresh_tok 447 | 448 | spot = _make_pkceauth(scope, path) 449 | spot.validate_token(spot.cache_handler.get_cached_token()) 450 | 451 | is_token_expired.assert_called_with(expired_tok) 452 | refresh_access_token.assert_called_with(expired_tok['refresh_token']) 453 | opener.assert_any_call(path, encoding='utf-8') 454 | 455 | @patch.multiple(SpotifyPKCE, 456 | is_token_expired=DEFAULT, refresh_access_token=DEFAULT) 457 | @patch('spotipy.cache_handler.open', create=True) 458 | def test_badly_scoped_token_bails(self, opener, 459 | is_token_expired, refresh_access_token): 460 | token_scope = "playlist-modify-public" 461 | requested_scope = "playlist-modify-private" 462 | path = ".cache-username" 463 | tok = _make_fake_token(1, 1, token_scope) 464 | 465 | token_file = _token_file(json.dumps(tok, ensure_ascii=False)) 466 | opener.return_value = token_file 467 | opener.return_value.__enter__ = mock.Mock(return_value=token_file) 468 | opener.return_value.__exit__ = mock.Mock(return_value=False) 469 | is_token_expired.return_value = False 470 | 471 | spot = _make_pkceauth(requested_scope, path) 472 | cached_tok = spot.validate_token(spot.cache_handler.get_cached_token()) 473 | 474 | opener.assert_called_with(path, encoding='utf-8') 475 | self.assertIsNone(cached_tok) 476 | self.assertEqual(refresh_access_token.call_count, 0) 477 | 478 | @patch('spotipy.cache_handler.open', create=True) 479 | def test_saves_to_cache_path(self, opener): 480 | scope = "playlist-modify-private" 481 | path = ".cache-username" 482 | tok = _make_fake_token(1, 1, scope) 483 | 484 | fi = _fake_file() 485 | opener.return_value = fi 486 | opener.return_value.__enter__ = mock.Mock(return_value=fi) 487 | opener.return_value.__exit__ = mock.Mock(return_value=False) 488 | spot = SpotifyPKCE("CLID", "REDIR", "STATE", scope, path) 489 | spot.cache_handler.save_token_to_cache(tok) 490 | 491 | opener.assert_called_with(path, 'w', encoding='utf-8') 492 | self.assertTrue(fi.write.called) 493 | 494 | @patch('spotipy.cache_handler.open', create=True) 495 | def test_saves_to_cache_path_legacy(self, opener): 496 | scope = "playlist-modify-private" 497 | path = ".cache-username" 498 | tok = _make_fake_token(1, 1, scope) 499 | 500 | fi = _fake_file() 501 | opener.return_value = fi 502 | opener.return_value.__enter__ = mock.Mock(return_value=fi) 503 | opener.return_value.__exit__ = mock.Mock(return_value=False) 504 | 505 | spot = SpotifyPKCE("CLID", "REDIR", "STATE", scope, path) 506 | spot._save_token_info(tok) 507 | 508 | opener.assert_called_with(path, 'w', encoding='utf-8') 509 | self.assertTrue(fi.write.called) 510 | 511 | 512 | class TestSpotifyPKCE(unittest.TestCase): 513 | 514 | def test_generate_code_verifier_for_pkce(self): 515 | auth = SpotifyPKCE("CLID", "REDIR") 516 | auth.get_pkce_handshake_parameters() 517 | self.assertTrue(auth.code_verifier) 518 | 519 | def test_generate_code_challenge_for_pkce(self): 520 | auth = SpotifyPKCE("CLID", "REDIR") 521 | auth.get_pkce_handshake_parameters() 522 | self.assertTrue(auth.code_challenge) 523 | 524 | def test_code_verifier_and_code_challenge_are_correct(self): 525 | import base64 526 | import hashlib 527 | auth = SpotifyPKCE("CLID", "REDIR") 528 | auth.get_pkce_handshake_parameters() 529 | self.assertEqual(auth.code_challenge, 530 | base64.urlsafe_b64encode( 531 | hashlib.sha256(auth.code_verifier.encode('utf-8')) 532 | .digest()) 533 | .decode('utf-8') 534 | .replace('=', '')) 535 | 536 | def test_get_authorize_url_doesnt_pass_state_by_default(self): 537 | auth = SpotifyPKCE("CLID", "REDIR") 538 | 539 | url = auth.get_authorize_url() 540 | 541 | parsed_url = urllibparse.urlparse(url) 542 | parsed_qs = urllibparse.parse_qs(parsed_url.query) 543 | self.assertNotIn('state', parsed_qs) 544 | 545 | def test_get_authorize_url_passes_state_from_constructor(self): 546 | state = "STATE" 547 | auth = SpotifyPKCE("CLID", "REDIR", state) 548 | 549 | url = auth.get_authorize_url() 550 | 551 | parsed_url = urllibparse.urlparse(url) 552 | parsed_qs = urllibparse.parse_qs(parsed_url.query) 553 | self.assertEqual(parsed_qs['state'][0], state) 554 | 555 | def test_get_authorize_url_passes_state_from_func_call(self): 556 | state = "STATE" 557 | auth = SpotifyPKCE("CLID", "REDIR") 558 | 559 | url = auth.get_authorize_url(state=state) 560 | 561 | parsed_url = urllibparse.urlparse(url) 562 | parsed_qs = urllibparse.parse_qs(parsed_url.query) 563 | self.assertEqual(parsed_qs['state'][0], state) 564 | --------------------------------------------------------------------------------