pax_global_header00006660000000000000000000000064146261651050014520gustar00rootroot0000000000000052 comment=8a40e038b9cf0daac3c12cffa1b17d04e098038b spotipy-dev-spotipy-8a40e03/000077500000000000000000000000001462616510500160365ustar00rootroot00000000000000spotipy-dev-spotipy-8a40e03/.github/000077500000000000000000000000001462616510500173765ustar00rootroot00000000000000spotipy-dev-spotipy-8a40e03/.github/ISSUE_TEMPLATE/000077500000000000000000000000001462616510500215615ustar00rootroot00000000000000spotipy-dev-spotipy-8a40e03/.github/ISSUE_TEMPLATE/bug_report.md000066400000000000000000000012661462616510500242600ustar00rootroot00000000000000--- name: Bug report about: Create a report to help us improve title: '' labels: bug assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **Your code** Share a complete minimal working example. **Expected behavior** A clear and concise description of what you expected to happen. **Output** Paste and format errors (with complete stacktrace) or logs. Make sure to remove sensitive information. **Environment:** - OS: [e.g. Windows, Mac] - Python version [e.g. 3.7.0] - spotipy version [e.g. 2.12.0] - your IDE (if using any) [e.g. PyCharm, Jupyter Notebook IDE, Google Colab] **Additional context** Add any other context about the problem here. spotipy-dev-spotipy-8a40e03/.github/ISSUE_TEMPLATE/feature_request.md000066400000000000000000000011251462616510500253050ustar00rootroot00000000000000--- name: Feature request about: Suggest an idea for this project title: '' labels: enhancement assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or code about the feature request here. spotipy-dev-spotipy-8a40e03/.github/ISSUE_TEMPLATE/help.md000066400000000000000000000005441462616510500230360ustar00rootroot00000000000000--- name: Help about: I have a question title: '' labels: question assignees: '' --- spotipy-dev-spotipy-8a40e03/.github/SECURITY.md000066400000000000000000000006541462616510500211740ustar00rootroot00000000000000# Security Policy ## Supported Versions | Version | Supported | | ------- | ------------------ | | 2.x | :white_check_mark: | | 1.x | :x: | ## Reporting a Vulnerability Report via https://github.com/spotipy-dev/spotipy/security/advisories. Guidance: https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability. spotipy-dev-spotipy-8a40e03/.github/dependabot.yml000066400000000000000000000007661462616510500222370ustar00rootroot00000000000000# To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates version: 2 updates: - package-ecosystem: "pip" # See documentation for possible values directory: "/" # Location of package manifests schedule: interval: "weekly" spotipy-dev-spotipy-8a40e03/.github/workflows/000077500000000000000000000000001462616510500214335ustar00rootroot00000000000000spotipy-dev-spotipy-8a40e03/.github/workflows/integration_tests.yml000066400000000000000000000013441462616510500257250ustar00rootroot00000000000000name: Integration tests on: [push, pull_request_target] jobs: build: runs-on: ubuntu-latest env: SPOTIPY_CLIENT_ID: ${{ secrets.SPOTIPY_CLIENT_ID }} SPOTIPY_CLIENT_SECRET: ${{ secrets.SPOTIPY_CLIENT_SECRET }} PYTHON_VERSION: "3.10" steps: - uses: actions/checkout@v4 - name: Set up Python ${{ env.PYTHON_VERSION }} uses: actions/setup-python@v5 with: python-version: ${{ env.PYTHON_VERSION }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install .[test] - name: Run non user endpoints integration tests run: | python -m unittest discover -v tests/integration/non_user_endpoints spotipy-dev-spotipy-8a40e03/.github/workflows/publish.yml000066400000000000000000000016101462616510500236220ustar00rootroot00000000000000name: Publish to PyPI on: push: branches-ignore: - '**' tags: - '*.*.*' jobs: build-n-publish: name: Build and publish Python šŸ distributions šŸ“¦ to PyPI and TestPyPI runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.x" - name: Install pypa/build run: >- python -m pip install build --user - name: Build a binary wheel and a source tarball run: >- python -m build --sdist --wheel --outdir dist/ . - name: Publish distribution šŸ“¦ to PyPI if: startsWith(github.ref, 'refs/tags') uses: pypa/gh-action-pypi-publish@release/v1 with: password: ${{ secrets.PYPI_API_TOKEN }} spotipy-dev-spotipy-8a40e03/.github/workflows/pull_request.yml000066400000000000000000000006571462616510500247120ustar00rootroot00000000000000name: "Pull Request Workflow" on: pull_request: types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled] jobs: # Enforces the update of a changelog file on every pull request changelog: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: dangoslen/changelog-enforcer@v3.6.1 with: changeLogPath: 'CHANGELOG.md' skipLabel: 'skip-changelog' spotipy-dev-spotipy-8a40e03/.github/workflows/pythonapp.yml000066400000000000000000000015111462616510500241760ustar00rootroot00000000000000name: Tests on: [push, pull_request] jobs: build: runs-on: ubuntu-20.04 strategy: matrix: python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install .[test] - name: Lint with flake8 run: | pip install -Iv enum34==1.1.6 # https://bitbucket.org/stoneleaf/enum34/issues/27/enum34-118-broken pip install flake8 flake8 . --count --show-source --statistics - name: Run unit tests run: | python -m unittest discover -v tests/unit spotipy-dev-spotipy-8a40e03/.gitignore000066400000000000000000000010741462616510500200300ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] # C extensions *.so # Distribution / packaging .Python env/ bin/ build/ develop-eggs/ dist/ eggs/ lib/ lib64/ parts/ sdist/ var/ *.egg-info/ .installed.cfg *.egg # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage nosetests.xml coverage.xml # Translations *.mo # Mr Developer .mr.developer.cfg .project .pydevproject # Rope .ropeproject # Django stuff: *.log *.pot # Sphinx documentation docs/_build/ # Spotipy tokens .cache .* archivespotipy-dev-spotipy-8a40e03/.readthedocs.yaml000066400000000000000000000004531462616510500212670ustar00rootroot00000000000000# Read the Docs configuration file for Sphinx projects # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details version: 2 build: os: ubuntu-22.04 tools: python: "3.12" sphinx: configuration: docs/conf.py python: install: - requirements: docs/requirements.txt spotipy-dev-spotipy-8a40e03/CHANGELOG.md000066400000000000000000000413551462616510500176570ustar00rootroot00000000000000# Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased Add your changes below. ### Added - ### Fixed - ### Removed - ## [2.24.0] - 2024-05-30 ### Added - Added `MemcacheCacheHandler`, a cache handler that stores the token info using pymemcache. - Added support for audiobook endpoints: `get_audiobook`, `get_audiobooks`, and `get_audiobook_chapters`. - Added integration tests for audiobook endpoints. - Added `update` field to `current_user_follow_playlist`. ### Changed - Fixed error obfuscation when Spotify class is being inherited and an error is raised in the Child's `__init__` - Replaced `artist_albums(album_type=...)` with `artist_albums(include_groups=...)` due to an API change. - Updated `_regex_spotify_url` to ignore `/intl-` in Spotify links - Improved README, docs and examples ### Fixed - Readthedocs build - Split `test_current_user_save_and_usave_tracks` unit test ### Removed - Drop support for EOL Python 3.7 ## [2.23.0] - 2023-04-07 ### Added - Added optional `encoder_cls` argument to `CacheFileHandler`, which overwrite default encoder for token before writing to disk - Integration tests for searching multiple types in multiple markets (non-user endpoints) - Publish to PyPI action ### Fixed - Fixed the regex for matching playlist URIs with the format spotify:user:USERNAME:playlist:PLAYLISTID. - `search_markets` now factors the counts of all types in the `total` rather than just the first type ([#534](https://github.com/spotipy-dev/spotipy/issues/534)) ## [2.22.1] - 2023-01-23 ### Added - Add alternative module installation instruction to README - Added Comment to README - Getting Started for user to add URI to app in Spotify Developer Dashboard. - Added playlist_add_tracks.py to example folder ### Changed - Modified docstring for playlist_add_items() to accept "only URIs or URLs", with intended deprecation for IDs in v3 ### Fixed - Path traversal vulnerability that may lead to type confusion in URI handling code - Update contributing.md ## [2.22.0] - 2022-12-10 ### Added - Integration tests via GHA (non-user endpoints) - Unit tests for new releases, passing limit parameter with minimum and maximum values of 1 and 50 - Unit tests for categories, omitting country code to test global releases - Added `CODE_OF_CONDUCT.md` ### Fixed - Incorrect `category_id` input for test_category - Assertion value for `test_categories_limit_low` and `test_categories_limit_high` - Pin GitHub Actions Runner to Ubuntu 20 for Py27 - Fixed potential error where `found` variable in `test_artist_related_artists` is undefined if for loop never evaluates to true - Fixed false positive test `test_new_releases` which looks up the wrong property of the JSON response object and always evaluates to true ## [2.21.0] - 2022-09-26 ### Added - Added `market` parameter to `album` and `albums` to address ([#753](https://github.com/plamere/spotipy/issues/753) - Added `show_featured_artists.py` to `/examples`. - Expanded contribution and license sections of the documentation. - Added `FlaskSessionCacheHandler`, a cache handler that stores the token info in a flask session. - Added Python 3.10 in GitHub Actions ### Fixed - Updated the documentation to specify ISO-639-1 language codes. - Fix `AttributeError` for `text` attribute of the `Response` object - Require redis v3 if python2.7 (fixes readthedocs) ## [2.20.0] - 2022-06-18 ### Added - Added `RedisCacheHandler`, a cache handler that stores the token info in Redis. - Changed URI handling in `client.Spotify._get_id()` to remove queries if provided by error. - Added a new parameter to `RedisCacheHandler` to allow custom keys (instead of the default `token_info` key) - Simplify check for existing token in `RedisCacheHandler` ### Changed - Removed Python 3.5 and added Python 3.9 in GitHub Action ## [2.19.0] - 2021-08-12 ### Added - Added `MemoryCacheHandler`, a cache handler that simply stores the token info in memory as an instance attribute of this class. - 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. - 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. ### Fixed - Fixed a bug in `CacheFileHandler.__init__`: The documentation says that the username will be retrieved from the environment, but it wasn't. - Fixed a bug in the initializers for the auth managers that produced a spurious warning message if you provide a cache handler, and you set a value for the "SPOTIPY_CLIENT_USERNAME" environment variable. - Use generated MIT license and fix license type in `pip show` ## [2.18.0] - 2021-04-13 ### Added - Enabled using both short and long IDs for playlist_change_details - Added a cache handler to `SpotifyClientCredentials` - Added the following endpoints - `Spotify.current_user_saved_episodes` - `Spotify.current_user_saved_episodes_add` - `Spotify.current_user_saved_episodes_delete` - `Spotify.current_user_saved_episodes_contains` - `Spotify.available_markets` ### Changed - Add support for a list of scopes rather than just a comma separated string of scopes ### Fixed - 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. - 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. - Cleaned up the documentation for `SpotifyClientCredentials.__init__`, `SpotifyOAuth.__init__`, and `SpotifyPKCE.__init__`. ## [2.17.1] - 2021-02-28 ### Fixed - `allowed_methods` requires urllib3>=1.26.0 ## [2.17.0] - 2021-02-28 ### Changed - moved os.remove(session_cache_path()) inside try block to avoid TypeError on app.py example file - A warning will no longer be emitted when the cache file does not exist at the specified path - The docs for the `auth` parameter of `Spotify.init` use the term "access token" instead of "authorization token" - Changed docs for `search` to mention that you can provide multiple types to search for - The query parameters of requests are now logged - Deprecate specifying `cache_path` or `username` directly to `SpotifyOAuth`, `SpotifyPKCE`, and `SpotifyImplicitGrant` constructors, instead directing users to use the `CacheFileHandler` cache handler - Removed requirement for examples/app.py to specify port multiple times (only SPOTIPY_REDIRECT_URI needs to contain the port) ### Added - Added log messages for when the access and refresh tokens are retrieved and when they are refreshed - Support `market` optional parameter in `track` - Added CacheHandler abstraction to allow users to cache tokens in any way they see fit ### Fixed - Fixed Spotify.user_playlist_reorder_tracks calling Spotify.playlist_reorder_tracks with an incorrect parameter order - Fixed deprecated Urllib3 `Retry(method_whitelist=...)` in favor of `Retry(allowed_methods=...)` ## [2.16.1] - 2020-10-24 ### Fixed - playlist_tracks example code no longer prints extra characters on final loop iteration - 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)) - 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)) - Enable retries for POST, DELETE, PUT ([#577](https://github.com/plamere/spotipy/issues/577)) ### Changed - both inline and starting import lists are sorted using `isort` module - changed Max Retries exception code from 599 to 429 ## [2.16.0] - 2020-09-16 ### Added - `open_browser` can be passed to the constructors of `SpotifyOAuth` and `SpotifyPKCE` to make it easier to authorize in browserless environments ## [2.15.0] - 2020-09-08 ### Added - `SpotifyPKCE.parse_auth_response_url`, mirroring that method in `SpotifyOAuth` ### Changed - Specifying a cache_path or username is now optional ### Fixed - Using `SpotifyPKCE.get_authorization_url` will now generate a code challenge if needed ## [2.14.0] - 2020-08-29 ### Added - (experimental) Support to search multiple/all markets at once. - Support to test whether the current user is following certain users or artists - Proper replacements for all deprecated playlist endpoints (See https://developer.spotify.com/community/news/2018/06/12/changes-to-playlist-uris/ and below) - Allow for OAuth 2.0 authorization by instructing the user to open the URL in a browser instead of opening the browser. - Reason for 403 error in SpotifyException - Support for the PKCE Auth Flow - Support to advertise different language to Spotify - Added 'collaborative' parameter to user_playlist_create method. - Enforce CHANGELOG update on PR - Adds `additional_types` parameter to retrieve currently playing podcast episode - Support to get info about a single category ### Deprecated - `user_playlist_change_details` in favor of `playlist_change_details` - `user_playlist_unfollow` in favor of `current_user_unfollow_playlist` - `user_playlist_add_tracks` in favor of `playlist_add_items` - `user_playlist_replace_tracks` in favor of `playlist_replace_items` - `user_playlist_reorder_tracks` in favor of `playlist_reorder_items` - `user_playlist_remove_all_occurrences_of_tracks` in favor of `playlist_remove_all_occurrences_of_items` - `user_playlist_remove_specific_occurrences_of_tracks` in favor of `playlist_remove_specific_occurrences_of_items` - `user_playlist_follow_playlist` in favor of `current_user_follow_playlist` - `user_playlist_is_following` in favor of `playlist_is_following` - `playlist_tracks` in favor of `playlist_items` ### Fixed - fixed issue where episode URIs were being converted to track URIs in playlist calls ## [2.13.0] - 2020-06-25 ### Added - Added `SpotifyImplicitGrant` as an auth manager option. It provides user authentication without a client secret but sacrifices the ability to refresh the token without user input. (However, read the class docstring for security advisory.) - Added built-in verification of the `state` query parameter - Added two new attributes: error and error_description to `SpotifyOauthError` exception class to show authorization/authentication web api errors details. - Added `SpotifyStateError` subclass of `SpotifyOauthError` - Allow extending `SpotifyClientCredentials` and `SpotifyOAuth` - Added the market parameter to `album_tracks` ### Deprecated - Deprecated `util.prompt_for_user_token` in favor of `spotipy.Spotify(auth_manager=SpotifyOAuth())` ## [2.12.0] - 2020-04-26 ### Added - Added a method to update the auth token. ### Fixed - Logging regression due to the addition of `logging.basicConfig()` which was unneeded. ## [2.11.2] - 2020-04-19 ### Changed - Updated the documentation to give more details on the authorization process and reflect 2020 Spotify Application jargon and practices. - The local webserver is only started for localhost redirect_uri which specify a port, i.e. it is started for `http://localhost:8080` or `http://127.0.0.1:8080`, not for `http://localhost`. ### Fixed - Issue where using `http://localhost` as redirect_uri would cause the authorization process to hang. ## [2.11.1] - 2020-04-11 ### Fixed - Fixed miscellaneous issues with parsing of callback URL ## [2.11.0] - 2020-04-11 ### Added - Support for shows/podcasts and episodes - Added CONTRIBUTING.md ### Changed - Client retry logic has changed as it now uses urllib3's `Retry` in conjunction with requests `Session` - The session is customizable as it allows for: - status_forcelist - retries - status_retries - backoff_factor - Spin up a local webserver to autofill authentication URL - Use session in SpotifyAuthBase - Logging used instead of print statements ### Fixed - Close session when Spotipy object is unloaded - Propagate refresh token error ## [2.10.0] - 2020-03-18 ### Added - Support for `add_to_queue` - **Parameters:** - track uri, id, or url - device id. If None, then the active device is used. - Add CHANGELOG and LICENSE to released package ## [2.9.0] - 2020-02-15 ### Added - Support `position_ms` optional parameter in `start_playback` - Add `requests_timeout` parameter to authentication methods - Make cache optional in `get_access_token` ## [2.8.0] - 2020-02-12 ### Added - Support for `playlist_cover_image` - Support `after` and `before` parameter in `current_user_recently_played` - CI for unit tests - Automatic `token` refresh - `auth_manager` and `oauth_manager` optional parameters added to `Spotify`'s init. - Optional `username` parameter to be passed to `SpotifyOAuth`, to infer a `cache_path` automatically - 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 - Optional `show_dialog` parameter to be passed to `SpotifyOAuth` ### Changed - Both `SpotifyClientCredentials` and `SpotifyOAuth` inherit from a common `SpotifyAuthBase` which handles common parameters and logics. ## [2.7.1] - 2020-01-20 ### Changed - PyPi release mistake without pulling last merge first ## [2.7.0] - 2020-01-20 ### Added - Support for `playlist_tracks` - Support for `playlist_upload_cover_image` ### Changed - `user_playlist_tracks` doesn't require a user anymore (accepts `None`) ### Deprecated - Deprecated `user_playlist` and `user_playlist_tracks` ## [2.6.3] - 2020-01-16 ### Fixed - Fixed broken doc in 2.6.2 ## [2.6.2] - 2020-01-16 ### Fixed - Fixed broken examples in README, examples and doc ### Changed - Allow session keepalive - Bump requests to 2.20.0 ## [2.6.1] - 2020-01-13 ### Fixed - Fixed inconsistent behaviour with some API methods when a full HTTP URL is passed. - Fixed invalid calls to logging warn method ### Removed - `mock` no longer needed for install. Only used in `tox`. ## [2.6.0] - 2020-01-12 ### Added - Support for `playlist` to get a playlist without specifying a user - Support for `current_user_saved_albums_delete` - Support for `current_user_saved_albums_contains` - Support for `user_unfollow_artists` - Support for `user_unfollow_users` - Lint with flake8 using GitHub action ### Changed - Fix typos in doc - Start following [SemVer](https://semver.org) properly ### Changed - Made instructions in the CONTRIBUTING.md file more clear such that it is easier to onboard and there are no conflicts with TUTORIAL.md ## [2.5.0] - 2020-01-11 Added follow and player endpoints ## [2.4.4] - 2017-01-04 Python 3 fix ## [2.4.3] - 2017-01-02 Fixed proxy issue in standard auth flow ## [2.4.2] - 2017-01-02 Support getting audio features for a single track ## [2.4.1] - 2017-01-02 Incorporated proxy support ## [2.4.0] - 2016-12-31 Incorporated a number of PRs ## [2.3.8] - 2016-03-31 Added recs, audio features, user top lists ## [2.3.7] - 2015-08-10 Added current_user_followed_artists ## [2.3.6] - 2015-06-03 Support for offset/limit with album_tracks API ## [2.3.5] - 2015-04-28 Fixed bug in auto retry logic ## [2.3.3] - 2015-04-01 Added client credential flow ## [2.3.2] - 2015-03-31 Added auto retry logic ## [2.3.0] - 2015-01-05 Added session support added by akx. ## [2.2.0] - 2014-11-15 Added support for user_playlist_tracks ## [2.1.0] - 2014-10-25 Added support for new_releases and featured_playlists ## [2.0.2] - 2014-08-25 Moved to spotipy at pypi ## [1.2.0] - 2014-08-22 Upgraded APIs and docs to make it be a real library ## [1.310.0] - 2014-08-20 Added playlist replace and remove methods. Added auth tests. Improved API docs ## [1.301.0] - 2014-08-19 Upgraded version number to take precedence over previously botched release (sigh) ## [1.50.0] - 2014-08-14 Refactored util out of examples and into the main package ## [1.49.0] - 2014-07-23 Support for "Your Music" tracks (add, delete, get), with examples ## [1.45.0] - 2014-07-07 Support for related artists' endpoint. Don't use cache auth codes when scope changes ## [1.44.0] - 2014-07-03 Added show tracks.py example ## [1.43.0] - 2014-06-27 Fixed JSON handling issue ## [1.42.0] - 2014-06-19 Removed dependency on simplejson ## [1.40.0] - 2014-06-12 Initial public release. ## [1.4.2] - 2014-06-21 Added support for retrieving starred playlists ## [1.1.0] - 2014-06-17 Updates to match released API ## [1.1.0] - 2014-05-18 Repackaged for saner imports ## [1.0.0] - 2017-04-05 Initial release spotipy-dev-spotipy-8a40e03/CODE_OF_CONDUCT.md000066400000000000000000000057051462616510500206440ustar00rootroot00000000000000# Contributor Covenant Code of Conduct ## Our Pledge Here at Spotipy, we would like to promote an environment which is open and welcoming to all. As contributors and maintainers we want to guarantee an experience which is free of harassment for everyone. By everyone, we mean everyone, regardless of: age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Here are some examples of conduct which we believe is conducive to and contributes to a positive environment: * Use of welcoming and inclusive language * Giving due respect to differing viewpoints and experiences * Being accepting of constructive criticism * Being focused on what is best for the community * Displaying empathy towards other members of the community Here are some examples of conduct which we believe are unacceptable: * Using sexualized language/imagery or giving other community members unwelcome sexual attention * Making insulting/derogatory comments to other community members, or making personal/political attacks against other community members * Trolling * Harassing other members publicly or privately * Doxxing other community members (leaking private information without first getting consent) * Any other behavior which would be considered inappropriate in a professional setting ## Our Responsibilities As project maintainers, we are responsible for clearly laying out standards for proper conduct. We are also responsible for taking the appropriate actions if and when a community member does not act with proper conduct. An example of appropriate action is removing/editing/rejecting comments/commits/code/wiki edits/issues or other contributions made by such an offender. If a community members continues to act in a way contrary to the Code of Conduct, it is our responsibility to ban them (temporarily or permanently). ## Scope Community members are expected to adhere to the Code of Conduct within all project spaces, as well as in all public spaces when representing the Spotipy community. ## Enforcement Please report instances of abusive, harassing, or otherwise unacceptable behavior to us. All complaints will be investigated and reviewed by the project team and will result in an appropriate response. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project’s leadership. ## Attribution This Code of Conduct is adapted from theĀ Contributor Covenant, version 1.4, available atĀ  https://www.contributor-covenant.org/version/1/4/code-of-conduct.html. For answers to common questions about this code of conduct, seeĀ https://www.contributor-covenant.org/faq spotipy-dev-spotipy-8a40e03/CONTRIBUTING.md000066400000000000000000000034741462616510500202770ustar00rootroot00000000000000## Contributing If you would like to contribute to spotipy follow these steps: ### Export the needed environment variables ```bash # Linux or Mac export SPOTIPY_CLIENT_ID=client_id_here export SPOTIPY_CLIENT_SECRET=client_secret_here export SPOTIPY_CLIENT_USERNAME=client_username_here # This is actually an id not spotify display name and can be found [here](https://www.spotify.com/us/account/overview/) export SPOTIPY_REDIRECT_URI=http://localhost:8080 # Make url is set in app you created to get your ID and SECRET # Windows $env:SPOTIPY_CLIENT_ID="client_id_here" $env:SPOTIPY_CLIENT_SECRET="client_secret_here" $env:SPOTIPY_CLIENT_USERNAME="client_username_here" $env:SPOTIPY_REDIRECT_URI="http://localhost:8080" ``` ### Create virtual environment, install dependencies, run tests: ```bash $ virtualenv --python=python3 env $ source env/bin/activate (env) $ pip install -e . (env) $ python -m unittest discover -v tests ``` ### Lint To automatically fix the code style: pip install autopep8 autopep8 --in-place --aggressive --recursive . To verify the code style: pip install flake8 flake8 . To make sure if the import lists are stored correctly: pip install isort isort . -c -v ### Changelog Don't forget to add a short description of your change in the [CHANGELOG](CHANGELOG.md) ### Publishing (by maintainer) - Bump version in setup.py - Bump and date changelog - Add to changelog: ## Unreleased Add your changes below. ### Added ### Fixed ### Removed - Commit changes - Push tag to trigger PyPI build & release workflow - Create github release https://github.com/plamere/spotipy/releases with the changelog content for the version and a short name that describes the main addition - Verify doc uses latest https://readthedocs.org/projects/spotipy/ spotipy-dev-spotipy-8a40e03/FAQ.md000066400000000000000000000035241462616510500167730ustar00rootroot00000000000000## Frequently Asked Questions ### Is there a way to get this field? spotipy can only return fields documented on the Spotify web API https://developer.spotify.com/documentation/web-api/reference/ ### How to use spotipy in an API? Check out [this example Flask app](examples/app.py) ### How can I store tokens in a database rather than on the filesystem? See https://spotipy.readthedocs.io/en/latest/#customized-token-caching ### Incorrect user Error: - You get `You cannot create a playlist for another user` - You get `You cannot remove tracks from a playlist you don't own` Solution: - Verify that you are signed in with the correct account on https://spotify.com - Remove your current token: `rm .cache-{userid}` - Request a new token by adding `show_dialog=True` to `spotipy.Spotify(auth_manager=SpotifyOAuth(show_dialog=True))` - Check that `spotipy.me()` shows the correct user id ### Why do I get 401 Unauthorized? Error: spotipy.exceptions.SpotifyException: http status: 401, code:-1 - https://api.spotify.com/v1/ Unauthorized. Solution: - You are likely missing a scope when requesting the endpoint, check https://developer.spotify.com/documentation/web-api/concepts/scopes/ ### Search doesn't find some tracks Problem: you can see a track on the Spotify app but searching for it using the API doesn't find it. Solution: by default `search("abba")` works in the US market. To search for in your current country, the [country indicator](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2) must be specified: `search("abba", market="DE")`. ### How do I obtain authorization in a headless/browserless environment? If you cannot open a browser, set `open_browser=False` when instantiating SpotifyOAuth or SpotifyPKCE. You will be prompted to open the authorization URI manually. See the [headless auth example](examples/headless.py).spotipy-dev-spotipy-8a40e03/LICENSE.md000066400000000000000000000020541462616510500174430ustar00rootroot00000000000000MIT License Copyright (c) 2021 Paul Lamere Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. spotipy-dev-spotipy-8a40e03/MANIFEST.in000066400000000000000000000000601462616510500175700ustar00rootroot00000000000000include *.txt *.md recursive-include docs *.txt spotipy-dev-spotipy-8a40e03/README.md000066400000000000000000000103041462616510500173130ustar00rootroot00000000000000# Spotipy ##### Spotipy is a lightweight Python library for the [Spotify Web API](https://developer.spotify.com/documentation/web-api). With Spotipy you get full access to all of the music data provided by the Spotify platform. ![Tests](https://github.com/plamere/spotipy/workflows/Tests/badge.svg?branch=master) [![Documentation Status](https://readthedocs.org/projects/spotipy/badge/?version=master)](https://spotipy.readthedocs.io/en/latest/?badge=master) ## Table of Contents - [Features](#features) - [Documentation](#documentation) - [Installation](#installation) - [Quick Start](#quick-start) - [Reporting Issues](#reporting-issues) - [Contributing](#contributing) - [License](#license) ## Features Spotipy supports all of the features of the Spotify Web API including access to all end points, and support for user authorization. For details on the capabilities you are encouraged to review the [Spotify Web API](https://developer.spotify.com/web-api/) documentation. ## Documentation Spotipy's [full documentation is online](http://spotipy.readthedocs.org/). Some function may need a [specific scope](https://developer.spotify.com/documentation/web-api/concepts/scopes). If you do not define the scope properly `ERROR 401 Unauthorized, permission missing` may occur. ## Installation ```bash pip install spotipy ``` alternatively, for Windows users ```bash py -m pip install spotipy ``` or upgrade ```bash pip install spotipy --upgrade ``` ## Quick Start A full set of examples can be found in the [online documentation](http://spotipy.readthedocs.org/) and in the [Spotipy examples directory](https://github.com/plamere/spotipy/tree/master/examples). To get started, [install spotipy](#installation), create a new account or log in on https://developers.spotify.com/. Go to the [dashboard](https://developer.spotify.com/dashboard), create an app and add your new ID and SECRET (ID and SECRET can be found on an app setting) to your environment ([step-by-step video](https://www.youtube.com/watch?v=kaBVN8uP358)): ### Example without user authentication ```python import spotipy from spotipy.oauth2 import SpotifyClientCredentials sp = spotipy.Spotify(auth_manager=SpotifyClientCredentials(client_id="YOUR_APP_CLIENT_ID", client_secret="YOUR_APP_CLIENT_SECRET")) results = sp.search(q='weezer', limit=20) for idx, track in enumerate(results['tracks']['items']): print(idx, track['name']) ``` Expected result: ``` 0 Island In The Sun 1 Say It Ain't So 2 Buddy Holly . . . 18 Troublemaker 19 Feels Like Summer ``` ### Example with user authentication A redirect URI must be added to your application at [My Dashboard](https://developer.spotify.com/dashboard/applications) to access user authenticated features. ```python import spotipy from spotipy.oauth2 import SpotifyOAuth sp = spotipy.Spotify(auth_manager=SpotifyOAuth(client_id="YOUR_APP_CLIENT_ID", client_secret="YOUR_APP_CLIENT_SECRET", redirect_uri="YOUR_APP_REDIRECT_URI", scope="user-library-read")) results = sp.current_user_saved_tracks() for idx, item in enumerate(results['items']): track = item['track'] print(idx, track['artists'][0]['name'], " – ", track['name']) ``` Expected result will be the list of music that you liked. For example if you liked Red and Sunflower, the result will be: ``` 0 Post Malone – Sunflower - Spider-Man: Into the Spider-Verse 1 Taylor Swift – Red ``` ## Reporting Issues For common questions please check our [FAQ](FAQ.md). You can ask questions about Spotipy on [Stack Overflow](http://stackoverflow.com/questions/ask). Don’t forget to add the *Spotipy* tag, and any other relevant tags as well, before posting. If you have suggestions, bugs or other issues specific to this library, file them [here](https://github.com/plamere/spotipy/issues). Or just send a pull request. ## Contributing If you are a developer with Python experience, and you would like to contribute to Spotipy, please be sure to follow the guidelines listed on documentation page > #### [Visit the guideline](https://spotipy.readthedocs.io/en/#contribute) spotipy-dev-spotipy-8a40e03/TUTORIAL.md000066400000000000000000000153711462616510500176320ustar00rootroot00000000000000# Spotipy Tutorial for Beginners 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. ## Prerequisites In order to complete this tutorial successfully, there are a few things that you should already have installed: **1. pip package manager** You can check to see if you have pip installed by opening up Terminal and typing the following command: pip --version If you see a version number, pip is installed, and you're ready to proceed. If not, instructions for downloading the latest version of pip can be found here: https://pip.pypa.io/en/stable/cli/pip_download/ **2. python3** Spotipy is written in Python, so you'll need to have the latest version of Python installed in order to use Spotipy. Check if you already have Python installed with the Terminal command: python --version If you see a version number, Python is already installed. If not, you can download it here: https://www.python.org/downloads/ **3. spotipy** You'll need to install the packages necessary for this project. Run the following command: ``` pip install spotipy ``` **4. experience with basic Linux commands** This tutorial will be easiest if you have some knowledge of how to use Linux commands to create and navigate folders and files on your computer. If you're not sure how to create, edit and delete files and directories from Terminal, learn about basic Linux commands [here](https://ubuntu.com/tutorials/command-line-for-beginners#1-overview) before continuing. Once those three setup items are taken care of, you're ready to start learning how to use Spotipy! ## Step 1. Creating a Spotify Account Spotipy relies on the Spotify API. In order to use the Spotify API, you'll need to create a Spotify developer account. A. Visit the [Spotify developer portal](https://developer.spotify.com/dashboard/). If you already have a Spotify account, click "Log in" and enter your username and password. Otherwise, click "Sign up" and follow the steps to create an account. After you've signed in or signed up, you should be redirected to your developer dashboard. B. Click the "Create an App" button. Enter any name and description you'd like for your new app. Add "http://localhost:1234" (or any other port number of your choosing) as your "Redirect URI". Accept the terms of service and click "Create." C. In your new app's Overview screen, click the "Settings" button and then under the "Basic Information" tab click "View client secret", then copy both your Client Secret and your Client ID somewhere on your computer. You'll need to access them later. D. Underneath your app name and description on the left-hand side, you'll see a "Show Client Secret" link. Click that link to reveal your Client Secret, then copy both your Client Secret and your Client ID somewhere on your computer. You'll need to access them later. ## Step 2. Installation and Setup A. Create a folder somewhere on your computer where you'd like to store the code for your Spotipy app. You can create a folder in terminal with this command: ```mkdir folder_name``` B. Install the Spotipy library. You can do this by using this command in the terminal: ```pip install spotipy``` 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 D. Paste the following code into your main.py file: ``` import spotipy from spotipy.oauth2 import SpotifyOAuth sp = spotipy.Spotify(auth_manager=SpotifyOAuth(client_id="YOUR_APP_CLIENT_ID", client_secret="YOUR_APP_CLIENT_SECRET", redirect_uri="YOUR_APP_REDIRECT_URI", scope="user-library-read")) ``` E. Replace YOUR_APP_CLIENT_ID and YOUR_APP_CLIENT_SECRET with the values you copied and saved in step 1D. Replace YOUR_APP_REDIRECT_URI with the URI you set in step 1C. ## Step 3. Start Using Spotipy After completing steps 1 and 2, your app is fully configured and ready to fetch data from the Spotify API. All that's left is to tell the API what data we're looking for, and we do that by adding some additional code to main.py. The code that follows is just an example - once you get it working, you should feel free to modify it in order to get different results. For now, let's assume that we want to print the names of all the albums on Spotify by Taylor Swift: A. First, we need to find Taylor Swift's Spotify URI (Uniform Resource Indicator). Every entity (artist, album, song, etc.) has a URI that can identify it. To find Taylor's URI, navigate to [her page on Spotify](https://open.spotify.com/artist/06HL4z0CvFAxyc27GXpf02) and look at the URI in your browser. Everything there that follows the last backslash in the URL path is Taylor's URI, in this case: 06HL4z0CvFAxyc27GXpf02 B. Add the URI as a variable in main.py. Notice the prefix added the URI: ``` taylor_uri = 'spotify:artist:06HL4z0CvFAxyc27GXpf02' ``` 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. ``` results = sp.artist_albums(taylor_uri, album_type='album') albums = results['items'] while results['next']: results = sp.next(results) albums.extend(results['items']) for album in albums: print(album['name']) ``` 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 E. You may see a window open in your browser asking you to authorize the application. Do so - you will only have to do this once. F. Return to your terminal - you should see all of Taylor's albums printed out there. ## Troubleshooting Tips A. Command not found running the application "zsh: command not found: python" Check which Python version that you have by running the command: ```python --version ``` or ```python3 --version```. In most cases, the recent Python version is Python 3. You may need to update Python. Once you have updated Python to the most recent version, run the command: ``` python3 main.py``` B. Encountering package error: If you are seeing an error "ModuleNotFoundError: No module named 'spotipy'", this means you have not installed the package. This may occur if you followed the installation and setup (up to Step 3, Part D) and attempted to run the app with the missing package. Run the command: ``` pip install spotipy ``` After the package is installed, run the app again. spotipy-dev-spotipy-8a40e03/docs/000077500000000000000000000000001462616510500167665ustar00rootroot00000000000000spotipy-dev-spotipy-8a40e03/docs/Makefile000066400000000000000000000127001462616510500204260ustar00rootroot00000000000000# Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = BUILDDIR = _build # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " devhelp to make HTML files and a Devhelp project" @echo " epub to make an epub" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " latexpdf to make LaTeX files and run them through pdflatex" @echo " text to make text files" @echo " man to make manual pages" @echo " texinfo to make Texinfo files" @echo " info to make Texinfo files and run them through makeinfo" @echo " gettext to make PO message catalogs" @echo " changes to make an overview of all changed/added/deprecated items" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" clean: -rm -rf $(BUILDDIR)/* html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." singlehtml: $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." pickle: $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." json: $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." htmlhelp: $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." qthelp: $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/spotipy.qhcp" @echo "To view the help file:" @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/spotipy.qhc" devhelp: $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" @echo "# mkdir -p $$HOME/.local/share/devhelp/spotipy" @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/spotipy" @echo "# devhelp" epub: $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." latex: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ "(use \`make latexpdf' here to do that automatically)." latexpdf: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." $(MAKE) -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." text: $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." man: $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." texinfo: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." @echo "Run \`make' in that directory to run these through makeinfo" \ "(use \`make info' here to do that automatically)." info: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo "Running Texinfo files through makeinfo..." make -C $(BUILDDIR)/texinfo info @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." gettext: $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale @echo @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." linkcheck: $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." spotipy-dev-spotipy-8a40e03/docs/conf.py000066400000000000000000000173171462616510500202760ustar00rootroot00000000000000# # spotipy documentation build configuration file, created by # sphinx-quickstart on Thu Aug 21 11:04:39 2014. # # This file is execfile()d with the current directory set to its containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. import sys import os # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. #sys.path.insert(0, os.path.abspath('.')) sys.path.insert(0, os.path.abspath('.')) sys.path.insert(0, os.path.abspath("..")) import spotipy # -- General configuration ----------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. #needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = [ 'sphinx.ext.autodoc', 'sphinx_rtd_theme' ] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # The suffix of source filenames. source_suffix = '.rst' # The encoding of source files. #source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'index' # General information about the project. project = 'spotipy' copyright = '2014, Paul Lamere' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. version = '2.0' # The full version, including alpha/beta/rc tags. release = '2.0' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. #language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: #today = '' # Else, today_fmt is used as the format for a strftime call. #today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = ['_build'] # The reST default role (used for this markup: `text`) to use for all documents. #default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. #add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). #add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. #show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. #modindex_common_prefix = [] # -- Options for HTML output --------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. html_theme = 'sphinx_rtd_theme' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. #html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. #html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". #html_title = None # A shorter title for the navigation bar. Default is the same as html_title. #html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. #html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. #html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. #html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. #html_use_smartypants = True # Custom sidebar templates, maps document names to template names. #html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. #html_additional_pages = {} # If false, no module index is generated. #html_domain_indices = True # If false, no index is generated. #html_use_index = True # If true, the index is split into individual pages for each letter. #html_split_index = False # If true, links to the reST sources are added to the pages. #html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. #html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. #html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. #html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). #html_file_suffix = None # Output file base name for HTML help builder. htmlhelp_basename = 'spotipydoc' # -- Options for LaTeX output -------------------------------------------------- latex_elements = { # The paper size ('letterpaper' or 'a4paper'). # 'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). # 'pointsize': '10pt', # Additional stuff for the LaTeX preamble. # 'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ ('index', 'spotipy.tex', 'spotipy Documentation', 'Paul Lamere', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of # the title page. #latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. #latex_use_parts = False # If true, show page references after internal links. #latex_show_pagerefs = False # If true, show URL addresses after external links. #latex_show_urls = False # Documents to append as an appendix to all manuals. #latex_appendices = [] # If false, no module index is generated. #latex_domain_indices = True # -- Options for manual page output -------------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ ('index', 'spotipy', 'spotipy Documentation', ['Paul Lamere'], 1) ] # If true, show URL addresses after external links. #man_show_urls = False # -- Options for Texinfo output ------------------------------------------------ # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ ('index', 'spotipy', 'spotipy Documentation', 'Paul Lamere', 'spotipy', 'One line description of project.', 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. #texinfo_appendices = [] # If false, no module index is generated. #texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. #texinfo_show_urls = 'footnote' spotipy-dev-spotipy-8a40e03/docs/images/000077500000000000000000000000001462616510500202335ustar00rootroot00000000000000spotipy-dev-spotipy-8a40e03/docs/images/spotify-web-api-doc.jpg000066400000000000000000001221121462616510500245160ustar00rootroot00000000000000’Ų’ąJFIF’ŪC ’ŪC     ’Ā$P’Ä ’ŚźYB<+ĒB•% LŪē‰l¬­¬Šˆóä Q/9˜‰!ˆ#˜·hą‘®P]4Ä Žlʰø²>pµ<DP2Čł’r̲A$+}0ęl"UŖÄFfķLGÆo,]oŠ#’HHKa¢š£Qs’±|Ķ›i®z‚ €F²Įā{«F9ĘH• -£“CĀā‡'Æ^ŖUµŽš"9š)‰Ą°rõõ“·[)ąÉŁ"iČŖ"QÅü$’[œŖZsjȁUpB½P@ŒŒµnįš³LŠšœ!B#°J ®¢ĄV»Ńn+@`Z+"5U%½õ„ęĒo•Äć/œ"_ „eh¼)WP ¦>Œ58DiW d@øxĆ ŚĄ„ˆ$x”BFY£€(1™ «&ø×Æļ2XKQ`‰uņŗģYÜYßmnā Ö<ŖC2: Ą†(ÓQx&^!§*ÖU #+g%Ūņ*Tö­ŖiŚhżڵā„SäN%ÕUtÓīĢøCeĘ@ĖŖėÖ6·;[ņČń†e[_&r€ĀMbČk(Ø%#:ÖB•:`NęJšj{NÓuŚŚšaGD6d”šā‰§ŖUR½~ä\hšŽ°øØģßeĖ­ĀĖ“[8*‘3ĀR0ĒŹĮĄė’[Ÿ ė†ŹėSØ%ā—Nhś~µZ–%‚Ą€!Ēlś7](š«‚9ĪdV Ø%ŁĘm£žRž×啋MŲģ·³0+V,ӃW‘’ģ7\ ®²”ŒŪ‡&R½| ~!ėTŒIζĄĘ Ļ¢› ¢i®ØćÉK\*©[Ųęuœ˜å6ĘoZŪ +ėsŖ¦!V®JōD,‡Šq…uUx³É»elļ½zK=?åĪŗDD–gw&ī« $„œĖ՞”]$—L ,g3Ć]Tū;6ŁO€Ā%!lŪmę·`J¬°fZk{ (™cSPŠĮlļnÄ T«_Éž{ÕĒ2Ļ,_ķzÕrŃ bŁĪEŁæBŌB„…U!8ö¤2!™“H¤”s2²Ó,°YĘ#‚0ü†6n]šCĄT«A`C—–Ž1ĢD5“źųŪ¢u҆+€†8[ ūĶs^ś”Ź•uJ.ø@ø†! yĪ;C‘[%,ČIŃ̤rpå'1˜F#ŹŚĮyƒŹŌ L…+/I*Š‘rįčÆ×~óīd«Ŗ+—X ®> /™Ļµųŗ¦.e(į·9,ŽF†\pšĄ‰Xž3#Y‘%‚.ˆł2»`ūc8Ź)“©¼Cju&żżē)©j*k•ōŜś÷¼}ŚuJh¤ň¬Ø³)÷Ö@’į$.<Ļ9‘ÄĒ=‹8Š‹ęg‡šĖ!DÓįJW˜lē,å…Śß,Ŗ»JĒK«bļc}*z zޱv-,±Ø ģ}bŌZżU]zKˆ”ē»Ć[[JYĜ4p\Ňßq³ē Õ×ć B\ĪHCJŽd!_<Ģi̜\ö÷’0zyķN¾Ö³{¶ģ™«×5-?[­ *­Oģöż¶vÕߜ|õS\ŗ«‡˜#ī}XgĢĖtü”§&­ßbrœ6$Yqšų‰2faJe;-Ņœų“¬ö ƒĘ~@«õ‡Øb’lي„¢i”Ź Š?~ī}³čēłżóö­X1Ź+ŽK+X”¹Ég 7"™jķéƒĪV§Į9€‹"„3>g™9ē&4ĪRĪ ®ĶŻÕ×T|ņŠ=O¤/eou`*[DŅ©€1„»_ӏEļžļžŌųyӕiØ śJ/B¬*śØ—<ÄI`Óę»rP‰™f f0p%ĮŠ3¬–Y™eÉDy5•åł’-śļŅ;ß^ŠŽVw©Õ4­ZŗŹ”«}ögcwǧ{ēˆ)QH+… KNÕ=r%‘®^œy—,ŚĘ8k«"äĒ1ĒXŒ`j®øøxFŪmĘ[)É(L?syof×̾Žģ’IõĪT7,cM­ėtkf*¦ 5/X}ī®žōæ`xĒtՕė%O¦u÷_é?Bº騸e™™†”!;k¦l³Źéc‹1äA%×\<;o:Ėm²Įg‘ū«[;kOų†»Ł½×t™.¬Ŗ¬­[QUĒIÜIżźNõŽ>WõµuMj5=}՝UלæŌž d—q#˜å&`æ·±²~|qhėŽDJ؅rJR#n:óĢ•–w··vĀĖŖ~lu’}ÖjÅšé šI(ˆ#Ź›§Ń[ś×ѝAóÖ„+j5^Øé®¦Ó«ž­ÉubY’f1ęc’°ŗ²œć”ÕÆIq#UZ('iŪ7ƖV¤ƒV–»’z#Æ×‰‰%UTVĒ8RDśĆźßszęZŌ¦e^tßIõu:Z ²I@8h³!œ6„“°$rf˜$\²šÕõė :Š Œ\![³e—,­ķ-œLĪŚ[8o™½C`(’p0%+Rį˜i–ŗßź¬>”uĒ…Õ®Eŗ“螋ėźä>·…U” 2Ģųv^(— b˜°–q“ę.¾Q"˜D­=H…ć-Y6{‹‡ķ™Znŗūyó‹ŗōŲ Ķ†)õŖ4ż…£ŗ/Ŗ~»ū›ęžØ+Ŗk(zŪ”:Æ«ŅśāĀ%Ē3ā°ncYXb+įÖ¦ĆLpxuӍEų5++C™×aqnõ‹¢# ¼įzߥ}UZÄFÅžŻ“ÜXŃ4š¶,/öKMs@ż[uŸ—B**kéśļ”ŗ­kUśŪcY bī W„„"¼Éšd+Å»špĮV½(ÄpZpģŁŲŁ6fŪ™aŒüąźŠkŒWĒfßw;dõ} Zæß»|ė^”CĢæ„¾¤„`M=]^—Ņ>}ź:ž±āxęēˆ:ēŒ8\3jDqˆƒ,^[—} Cxb¼ńw 7-\œĢŽĻćĻ.jŪŲµŹkn×ķ]¾īÖśób±Ųtœ5};ōÆčOyvG_ųĆT¦@Ołū¤é”õD„™p½p$YeŅąX”€$L™† Y–öé²NHꚸ0q„ra¢ę&q“Y¼Bøż±é’Œ»‘g}=č½eĘ÷ŠfŽz„>ŸŅ>/&Ækökѝįéź’ŚÖ KÕ>wč]wŸO‹@Y H”3­fĀ«-‘£óbŅåܕŒ/]¬×ą°^$i؏,1įÜpÅøõa{_°’!µīŽ÷÷ĒMu6³­éśXõ\Tœ’“õ?Ż]‘źGx#Ąsnwę?£ļ¾—õĘ”ńž2†“ęŸ8õĢ~LQĀŖpHƒ‰ˆīE§Œ`;EJ]ƒeh`Īb­ā<¼ūrˆ2r` ĪÅŅŗļż_źČł_š¹©4}4w6ļB«^¢§C,D”ķ?¦½Éé?vė?(”$4Ÿ:y³®ĒõŹ‚k­‘W¤‚Ć˜)˜™J،Ė#m_mŒ,¼ä²T ‡ ćĪa‹ŠiJÉķ§¹;“;ė¶vß*žQūś»QŃźZ}”Y[TŒœŹąÆģ?ośkÓ^Žņ/—˜[\ėo7tVœ/Ø ld1Z¹@,)µ‰%9D “㠘ö»uøĆ ŸT”_3oŚ˜ÉĶÅVÅĒgvē£{hڶ-³ ?&{“Śę™Æq©‰U•E>0azßÖ®ŚśŻ_.Ķ.‰ŃÕÕøśpéÅeVā ē Rqaūe'Æv[I*hµŹuXŲŗŃ$ĖeœˆßcfÓÓŽ„Żoģm:kņsGgC­RqćUĀÄBäx×¾³ö÷Ōæ:łŒÕ:ēJyŪ©hńϦq`Ҋź‹˜W_˜±2dœ[±jfĀē`ujŹzZŹōGĆX:ĆM±`ɬ­]¶,öw­;¢ßgŠæ8ŻN*Ф‹`Ą–X#²Į³šÆl}7śµņ*‚—_ė?>t¶­Œcéx3b+ĢńP¦ØW^8äd\@Ö Īfqē ŗ)¢ŠkCŒZX2yŅŻlV|…īūƒŃ©ŁÖžsō¾²š‚“lDA„F Ųؘ€ź;³žukŚw]twIčAē#ō¬A‘K,¹lōb+Ŗ°aÄe,ŒÄŹĆ¶LÄŗk*"Z[Y³O-ķČöö˵żSŁ[äĒē/É]#×KX3Ē$@„‹K—~—żkŅy{Iė¾›čŽ¢«ē1’Ä ’Śü#Wps†ī‹¶––$a"°Ņ˜N†Ēh³ø$#R“4°›§D{^Ź«¶ ’#EĒ“p)K0iTƒrČžĀŖZōléhŪ­ķVaJ20īÜÖ0֑Z®čˆĄ(c-¶ē;AµŽeŻÓ  „÷=¤K »Šė¦³5_Ą™Vb].‹¶ésYYRµ  ó…Ų“ćdiP,3Ź{@5l© ęŻ:ĒŻX.– ač~ƒ™åRģ“ia>($Eš[M°ŻÓŚż sH̼‰d„®[\±µP/:³ÖÖ²Ø%±Ģ=z„åV4°ÕU5k ³HāŃ”…”‚µ¬Hž ¬Će£~ͤĆaŠ-A²®Ä†““©¶ʔ-ŌŻ§i¶[tµĶŌŻL1ūśŻN—OØž_ĻxcAV×éŃ ģµ Āļä8+[š{ŲzØs%"Ć%“č«AŁP•'6¹ÅŻ]7¼s!Ī"~ {ß”€ĶŻ}żnKcjY®wĆšŻ?V­#DÓ„»ł¾ 9”»»'i­#™:ōĖd»c.s<źÅĶĀ’kݹż&¢šŪ²s“iÓæŽśNĘŪeŅÖ2įY]cš’4L=šX@Ld /‹‘k”ØÖf\©N`鱄©“%v`9īӗ=Qś5·£ŌŖ¹lsķš?B÷’Ŗ¢‚¶¢īģīƼ/ĖVę;K lŪP×/Ćdä"€Ć;łŸ6ftZZh†:Q@¢ÅĻ…«^¢ŗ{44Īäż_IśŽ„Ō«ķ&YÉ\æˆ.‘Zę0¤øUw?Ō ¤fVœėDÓl(1‚å"†ß0än‘×”¬ Ė£³­–NŠŻNū/¼XIT 3—Deuó/0*¹Ķ²—uV%>k Ö  ‚Ҥ¬*ģŽŹ ”0–’[5ék ±#”£F–ź~½Lū½@¬ō2’/уĮüö‘µÅerŖÄoåUm€#@UK°tWqT) ™l–QŚHąęĪķŽŁ§Cuč/©ż\PU7Cöi¼ł‘W^oć÷)‚ć—tr䋳Dk@*ŖāQc ‚ā²³iŖź—” Øj t@é{thv—±žēī,&²ā3 u;VƊ2 O/Ćń6[D„!²†Ćädķ6s:¤…`µäŹ#z5¶…teyÕK¾aé79Ī{»_„Ū§H ófQ;N½ŗMyóÓy_9ńćLaČŗ&оBzµ]UIH ƒV¼ŁÓTŠv½7dšb ÖZēhqi~ÆŅžŠm†|ŁÄ›Æ£½Ńk¢Ćį~p86j rƒ?Ź‹V³Ŗ•) •CJĀ›1Ī7£uŠQ0­rʁEcT RFݽΦhÓÆģT-ā¤#2†އGI ĶĀłĒ3ϲgœ‡k„ˆŠŹŻz*¬e„" 18 Ąi»ldaC²Ŗ©"€iIXÓČ“ič=žÓļ;wZ3fXQ““ķŅCUKGĢų>w'wč>?˜Ō•-ŗŗqiŲAR]eRŖDäø“‚W{_.ŁpŚĖ"*(„T«)[zZŻŠż7Ócэ4Ž7=¤2Ŗ/ĮųŽ/ŠūNwmRó(ŗŻŽļęĆn-ŗ«”œ‰¹ ‚³-UZŽĆ»„걆Ƃ”„ JĄ%°Ō¾GS4~õ5±YT7 ¬2+«’«Ķ|ē‡Ėõ=¬¾L‡:–Ķž›×w›÷1•B RŖČqēNpR£q§E-Ś41¬8“ēB’…,ĢS§³·FæØż|ŗ •QY‘]Õܒėʼ’?æŁįņB³,zž§ŪwŪųŗ^ÖŅĮ`4LˆĒ—0$ ö„8×,Śż“8Ž…(F|ź)K„£čkßź~óŠŲB2]ܒK²6NGƒšų½Fļj„ž“ßzfć*cŽl°‰²åfĮ˜ČeY)ŗ^ÓѳQŁĄJ2!kFl«ˆŃŲémݳō×@ß*ä’T²cZ÷;ĢųĻ—³ŚłNa„bOaķŗ‹æĘ6Ż#“ˆŠŠĪŲɓ0ŻVŒ¢Ż:ZnŪ°É” œŠR2āȍY÷ōvģÜļæū1Ł$’ŖŖµkŠęŸžņ’ō=ļF(ė{Ÿfįoāćs\e1jĪĘ]›•š¢Š„ĖϜCĢŪ»a™]Ņr„yqaĻ“”[w;VēżģģŻRP€€µt7č•9Ž{į^ÆĖ`;µfW[Ż{vĘ~.cl!A™"MqBdJEi]Fu›™dķZŚVeA+͇|Ū¶źŅmŁ§ÓżŪ¤Ū—P3NĪGH '•šÜļ TDŃŠ÷>ķöŒm†F@£`fD&9„&c™@•m9WdFÖ;F‹³²„iĖ—*O©¹Ģ'čŪśŅåUéj󢩚sp¼ļ–ņæVūsø¾SäĀQńY’Wō>€Ÿć[«q¬l³¢É†L1UTGƒĶō“č™p>qZÓ$Ū§FZė’LĮÉ>†ūó¾KĢłÆ5ę°u5ō<īč‡KĀųFī¾Ž6P¤źī{®Ä/Ērõ0DbŹźŁ„”4 ZʗĪN§ŁęoI–6GJP-p ž×ļŪ©_YśVo2|_‡—Ń£»E†¹Üī§ÖæZĢ|/„ćo³õ?2Ä }ÆcźD’śöP!#KŽknK’׉v{4Š5‚ TĖ eĶZٳOŅ~{ļ¾Ā^1_ųēŽ<Ÿēó:žŸŌśļgīż *•ē~a泝ļØs¾|©]?Wģ4_āś¹Me±Ę,4fb³Ć#:RBх³)³cܱ#¤-Ö8ĻsAŌ{=«ņ~ߎō|_7ŸšĆõ“Ńś=„§V»–ļ%ņž©zO“¦/£é½Næ Üv—)ģ5 “œ%ĘZÄ(EHÕJ}³Cb„m‘L=­)§YŹ&mś®|~ÆÕö<§›zZ¢ULŌ÷=ä +Åņ_)ĒśĒ«ł…ō½GØź_įµ¹‚Żm{ÅÕēRØ)2X R ¤ŁŽˆjQZ3ēMé×¶…Ūp»æ^~§©ļ÷x~Nõuz,Z©­k®„VOóļ9ōļ“”ÕÖō¾‹ežIŗę†SÜʘ° ¢mݹjRŹ89Čī֔c-ŽÖBo2Ūķ·'nīŸWŅö9u:z}=Īīä¹Q~Cå¼ļØł„‰u{Ž—ŖsšņĒLq•²Ęī­`1¦ŁK*+%ę'\ŁÖŻu“+_ŠzjĻŌķõöō»<ܙōmŚė«—$ŗ’ģ2žsģūæ$ææź{/šż®™wgug-…b0aD —C —vU™š/2„ęČū÷}—Ņö]G—׳M±Ķa]ʹ$’_€ūžg"ŪŲõ>…õ/ń —Mc!Ęܗó&„&–ĬÜė‹Ē˜ §]†—õ½©ėcżÆźkčš;zĪK’īȊ€a”åüē¹į×£„õjIų”ޤd]­ÉÄ֙]P(Ea¤čF­­i†Ly2˜yśU¤ļ›æÓt=yW+Ńld’‘±¦)H‘?3Ķń ŻÜõ~‚T“’Ä’Śś•ZZR‰·L§,ځ<ŹŅ®­(Ė)™4`šR†K9)ؔ(™‘2DDūUZ:Ҙ„COQÕHÕ6‚–Į#1ézց9FyAM¤!"‡,"!É$"TŠ[PLē’÷j諪D@¦ōulBNŠ–"i‚SšzV·v(ŒņĶ4Ņ%²“dLꤔ’R)lJ'<ćčÕ²©“)Y­iÕÓ%&ہ”Q8ʶō½(qœē( `Ę"b'2 ©…ĒŅĖc”ø"SŖwwB‘¤ŚœŽ—uwDg2ŅLTÓ2äß0Ļ/£ėI )™åFr¾ŽU SJfK¢µŖ±!“% lˆ“M.“Ö¦k,ąI'Ÿ?&<ø`Ź©™G‰ś‰ Ī]53Æ„ĶĢ©(S2ō²“ŗlB6FNļK½*Bc<„N/>u4īé,»¾šR&TĄ©“39ż$Īp1%5­Ó֛¶’"Y#F%m¦—m³8Ė89ž; ŅŌ:¼ö@…§Õ%*%)‘²eNK’Ļ„Į‰é”zjĘ )†ĘŃ«3ČW¦ŪXTĢÄc?;āŽšé)Ŗ&PŸmU꒕€#?§Ć,yó‘Ų Ż:×Mµ$©y…67H˜Iļ©£™’sšņś)¦Qޔ҄&ń÷}'1$Kmg?¦ēēēĀ$w@éŗu¦ aL–€ŅŹ’Nmė­]9Čš<­i…PJJ&öÕe†ßCS)‰cP¾“—Ÿ›)™.Ø*“uWd™Īr!Ž«J@œ“#×] "W™āźŲŪk<²•¦ŪlG/?ՊR˜J€bSļóįĶ)+v©–MVj"%IÕYI‰JnõŖ”LŽy<=&×}ž<ųDštmk›ĢśźAAMTĢϹŽ\Ń2š)Ž›uWS1Ħ!Õ=)-T¢*L¾|É^»o¶“?>(ÓmL9»½‚š”›mTĢG³†Xf€JšwEčŚJ#,“źé°@6SR‘sóų‘¦ūk¾¶±åäē—vā_Õūž§™ćóB ™‰ō3ĖÄ܍§wU²RŖ3Ę!¶ÄUU1€¢’™Bu§›ä¼ļnöŅČēćäČžĒõ:ü÷Ęł$ƒR×+Ÿ&')Õ½6™mLc0Ų eX…L I'*µ¾3{uōkNrĖ››$ =’|õż ćž'¢”4Ȍpļ',fĀsUE7¦”LK<¤@SL¤&é5M”ŅkMĖg&Ż{Õ¼±Ė,s ż“ī{ł~ćyŗ&@3ęåĒ֕žrg%¶Żō:t$SC rĒn”“ģwZ|æ#:w”g–yĀ÷Ÿ¬z<ß ął])Ę|ųrr{ó32”ÄĮLnś.Ūs9ĄWJSu åtĘīō §ē|ūεՄ) ?k÷sÉłĒøigĻĖÅɗŃĢĄ¦T)č½w°&!3„7#$ŅŠō½“×L³_›Ļ'M¤„é~ēõ~OÅxK4ÉĖƒŠ+ŚJT¤”M•Z]ŠfRB×BiәD$;Tm®÷¦śFQ9ü|Č 1FXcĶχÖ~·śoĖ~ĶyŖ‰ĖĢēµė¤å$Ü*m‰ŗvęe5%iAusœB.“½ŗu»čŃežsósމēįąęĆ(ś½ž‘üÆįū3ĢS9qłI/Q¶åU&9–6ķ!I3wTōŃØĪjiŃÆGNŗÖĮ9āy?;2uU”Ÿ“åó6zŽļõoćŽmFRƒ.2Tś…²­Ń)ĖyŹMŻ1J„®ž•¶”¦$3” ׯ£]n„²Œł>g£…¦•zqy7.½:?¦ż+ĘźĪs˜§—•+?TuZ^”„P,ęe¶čig*ī«]­“„éÓ¾šjÅ9e1ņü]žŒņs^¶čĖĻń¼¬^Ż<’CźżlÄćœé¦\¾F3>³wzéT ‚&R”Ų*ˆCŽŻéØ¦Z!Bnė^t­ Ļ×OƒóޟÖćäłPsņqņrdĖĪt_Ōż§£ęüHÅź—7™ÄēÕ·„ļ„4ĮŠ1 ŒŅ½4ŖŖ«”šŻÖ»ė„%†1īūߜųœß¤aó?Ÿį€j)3f»ž³ś#Šń?6ł`”cēł¤{w¶×MšJH)ŽsĪt»ÖĄ«s)0uN¶Õ‘”ļõßGłoĀt}_ē’ésā§§³¢ņąąČ{}_ģßaÓššič½n3öōŅöÖčcL©CNħ%EģŁ%QT„ĮŽDūŸ{ĖłoĘōū~ēē|ż=Ž—ÆÆ/'›ēåzéźž‰ś§Æó•xžkxy¼«ß½6ÕÕŅB(R³Wi¶”ŹszS&mˆ…% »s;{žĻ7Ā|ĪżQ\žGŪw—77?'2­/_g÷ļ¢óæłĪŖ³/;‡œś;ÓMčIYĶiM© •7WIE1‚MĢČĘŚ]¾Æ^ŽĢxŪGŪiäe¦—LJj€›“¦Ę…)¢€™”Ŗu¢™˜”3«é{½o˜ņ8yųśWW«Ėēy¼ņŅ‰æ¤žØü—ņĘ»Ō×·»äžC—ĒŚū|ž¼0Ā€JbļūłėĀ3˜Ćr}UŚĶ;»f¢DŠÕŖPH(™—@ō»3ŹŸŪoWÕāx?5ÅĶķÆ)”Js*ŅĪoģ¹šXć#čiÕ)®šĻ9Īe*4¦Ā­ˆq±]Ū')¹G½ö„ķ·Ķų|Ÿ=qąųœh•扶ō¦#ŃõuļēåĆ.t!’Ä%  ’Ś õ5ś{+ΟV£Q¶-¹µ½]Ļõ&ĀlJĘāéX™$ ±:ßUˌ|÷v-å?¹Ęjź_+„xõ’ĒńņŁÜѝŁń‰Ęq@"ĀQ”Ę i×ZŚžµĘ%¼ŌxĻ €łóēÉŹ cć›R•`߃&{}^ϱm›^„³ĀäĢóÕemmIkITŪ\ČLHJØS(ʞóƋųE… ·Įž’É>S(#6OJIęUĢI$"!ó’kÉP)PT“l“ĒF™B„@ł™|Šx#Ó÷Ė;“•ZM³ŌL¦{š½¬ŌĘʤ ×XĶ!=m]Hi¶øDÅ ¦Õ(©āøąµIÄéóō2¬¾A[,Ą'“f%Zm&‹É§ó¢ŃJ‘įT*…‘Œ‘@ I†>mÉŌÆ„äŅ ‡<>1ĘĘĄīĶF$Š-¢ŗc‚¾¬ń@Ą­H&ŗIu£©¬ŖQ¦¢X¢kT²c’ ž?ēŹ€€˜qƆĘ4?EžM×õ(ҬÜx¾0euõšø ]y "śU»6ÖS b@FVR=H|#ąĀ0pŲŲM GŅNŽ[Gw–µÜūõĆum }=šxXæ±o³?æ·Cu’%„¶S±biļōś{œb尊 ƒŸd¢•*įüŒ± „*ĮfŖØT\ čČBg›Ņ„¬0cR” •ˆ|ØõŹ5›ÉĮƒĶRb_R)ŽB|ö-×½tō„Ķ}֟A_¢VܘbFrģĪķ‡PĒ4f4,ĢŽŽā¢ŸBĢX³®WńLŪČŖŠU\9vräųQ˜2™ ėģĶĖ9fś=ķ³öö“‡.hÕ"³|lōd²×g°pń2HČHͤš¬Z R;ÉĶu–…*ōĮś3…į7x}nžN—¾Æ†Ė19ē7ófn” [] 9ķļō÷j5Ł©†~«žŠźUƒ–8Gž&źSÕćyn’č6ūīźn6Ą°¼­õ{Ī‘Åü±ÅĒ̓RųY›Ł__5İa_R­…%Psŗ<·3w`…õYq<—'Üīq¶‰ŹeēŸjlv‹Øö¦ĀĖPä–gfĘ%‹ūżß_”-„gŒcåJæ°#ó׌µ[-EÅĘÓnĻõ¤ÜŲ\]6'“kī^ -Ķé_cVµ2øųćĮ 5²L”ćĮK%“ÓŗŪ)č³Y¤žex,ZŒu5ųM~’?f[ZZ›ķ“n,Õ÷-l`WÓ䱊Af. #ǭҳXwdž3Y‰|Õdäœ_ž#šļ£IS‡ŽĢv…ˆ}~r_ΜćĪ©Tŗ:°|bēųŹŁG,7·‚<®yöóēÜ<ˆ8? Z} !LœžFFlū£+ūyūĻb{'dģŠū‡ö 2øĢ[.&D£+«{ūū©a$ļ ĢČĻWˆ~讓e¬²cxģFÓ²¾1W…cIš7VÆņ—R_UõﯳńšįĄĢĪXzśćęÉ@؇ϐ}Ž&L†-ļīĢ rDA?‘¦źqOŃ_Ų··ŅV÷Z}=Ōūօ† 1q(®ÆīčŌwÉ¢ā9lž„ĪGGnO5Žø’=“¶4vtöad”÷£“›•Žę­ŪØß™…uv“ļ©]zʓu*ĄįĀ śųńž“[+ą>sϟ+‰žNxpYI “PŖ]źž0'&ŹĘ‚Ā¢ĀĀĘ­O † ­72Sģ/ōś5}ę5§w§¶kw¹-~BI®¢iF:“ÓŲŅŪŃŽŌŻ…ĆŸŖģŚnŽõnĪsæēāŗ †ģ÷ņržo­hR ™—ĻŠĢĢ®ņęʘŸo>IVFóäų2B3UQåO’N\S ?±ĄĖG¢Óč*”k{†ö ¬øŒĻ8 Y¶Ršķ¬Śēś{„¹/„w÷”Ék^ޤt–8sa6åæF;IEešq±—“ŸńĻqś×ęz›éŽC‹Ųžžže¾­5Ž ‹H§”J#ć³–4Ėē°`ŽžžŅ pĄĄŃ0*žåžæ_lز®xĀY¼ų#Ųŗ°§Ūż"Ė@žĮ© ®*“ū6ĆŪŽ&Æ~ūq\ŽnˆŲä“vµ“v–Ōmƒ½œ–n+”™‰‹I§ŹĒ“¼×kz‡GcƒŪénS§v£ņöśŚō‹Å¢Šh|ZVWŲÜ£1l¾6óϲRn gŸ#$ŠaųpT@”•ćéųq€±-ō56ū „§eŖŃY`Ź|‡ū{"Ęp”%9ó\gRĒ’NĢnrZ|†«)¢__b{ }ķėl1ž¬.œ^×hyŽo‹·H×§G_ķN+cVŗŌƒĆāš2ŲmķŽCs“šŹŁUd#{{`3`Iöö›ė89į Q”ĖcU6ģ--„ݧ#³Čmīm^ÆķģYŻŻÜäķ?Ņõć[”—…}_ļ_ZŗµÖx4+-ŗņ<‡'Źņ\Ž÷/ŽN:ŃYY@UULī\Ń)­d²ŗbž{{ҬĮ ^ž…>M׬]>$:Õ+:#«£«)R3ĮC®“”ą²Yd13śo¦9 ^•åw7¹m­ķ†Ųmŗļ[nŪ„·ŃŖōf,O+>Š?jyN=;{5xŹw/Ŗ*µJŹŅtŪ<¾×1Éņܦ÷%³ŗįĒ.Ķ‡<āžĘĘÅÉÖ hŗ··»Uœj`U‰£c哪>68h–ēiŃ*µZ­§‚FS]|LL“MūėÄurzüWSļņ›[eÆŁėJŅŽōw.ꅣ‰šW~Œåūqœō xĶ/čn°ŖŠ:Õ.—•ł¾ww“ßŲ»²į,Ō,ğĄšpBŒ¬¾˜¶ŽĪ¾Āģ¤ģµÅ Ķ,š²ŃkŽÄ¶UmŒ}‹”Jʲ ¢Õm,ŠĶZ^³Č¼²x™ÉčõnÆ&œ#]ēŲ[}^“£;³—.IĻ‚K«©}ŃŪėö’@7rzĪŁ\aE°ÜĪb¼ī×PķķŚGĘĒ4,I81@‰čpć„0G…Ņ’čū­Ō«{{G‹9¶P66W61’ł “Q)7ŽG#’>LĶ”I²?×¼|'! PŲÓź-ö4'ÓąŚŌ…dT‰×]nØŌžaŚķtōĒ_³PVtów9ŃŌ4ē«g£361l|e#ĄU1rA 'Ī>“#– Œ…HsAIŃ+ōv£;aLŲ0”_ĄdÓ`Q¢a’ɱf(ÓiQ,“5ž„āy­KēŠæ° i¤“åĘ'ü>×·©Y15Œ'Øŗ{ĒuÆm: Bż[Ō%_-–kfęs™ŌćØrøq±±°ć)_U_V¹?6yóå±J8Ą(ĀK«ĶŃÅŽ’”Ź„&SŌ©_OTÅ*bŠyŗ4)īJā²RTZīēĪčĆ= żdŗpŅŠÕćŒ·æ„ČėmĢäĢN ÖˆÖźn;²SŚž7ś'øLXŗÕl*+›£œŸSߕ²˜ŲIĘĀ1‡ŖÆ«Æ„ČŲĮƒ šßˆČį•‘w.TFĻC#&V-Ž*}= •šŖ˜Šy'•R«eŚK+®¶]”¶Ōŗ‹å£§Ņ}:ĀcĪ8j(]…Ž)Ņz…ć?–øæåżnÄõĒg–›-ÉĆł#¾7ŽśZĻl¶W.9™u6æPĆeižŒĄśüž'^’>U– 8q’ zųfņø­ōZ„¾¬Äƒ6֏Ąė•“ļ„|z³xō*G€ŠŠ“A10„~ßxķKgī*,µ8Ž ½] ½«ĘҶžę¹5ąx7·ŚŻ³lcŪķ^Z ’ēÜælŗ«‹ōå-]¾ø‡šw[ōßGpzėŪn¬žMźNžµ,޶Ÿ'.£Õź-NN€yr‚ƒČŖŁ,–÷,GÆĢĶÕ×Ō~€˜„H_”欱Ļń°œ†½fŹ+LĻŠ" ˜ ŹąŒóļģZ ZtNtŽĻ«żĶüWb°¼:G ø¾ĪvŪ°’Ęt‡!ŠŚ½œé~ŸU½å»­Čuvę’%ÕŻĄžƒ]ĻõõBö‹Ø{OÖ\o/ÓGÓ¼§v»MŌßó‡Sčnš‡oł^*’Ś=¹}dלm«ŌœTiu©@ž¾ *œ|'(ƃńŪŚNµ QBĒœ8ø|2ÉtŅ3ų’œKŌJÄaźa )Įcņi™¬„VE=P"üĀēCj ®Žq}*ĻśĶŠ]¾ŲŖq|ߎnG¾»_Ń·ļĘĒsv¹Vm—ÜmœØ8ø3Éo}łöŖzg•āł®Üu ·ß)ā²ōqĪÆXkõ$Ż}D¾,(¬ŖŽ”O(}¦|†GZ{g‚Œ£įÅĻIĻJq—£ž…k›‚€aʲœŌ/©ŸĢMeó(źry0 §ĒlO§t:‰ééźæżičĪ“Ś|Ü^J{*Cb[K“ū[5»X³)ŲŲO.©ŚķßSźņ}®Žį7{įĻē©]†ęß­[Ÿ5_„h0‡™šŹ©D*ĮĄĮ€ \ NSńqzŖ"Ķ4³] &J&•—o(Kg„Ä3*J”‰čRŖf‹%EįŗS‡ķæŪž :s…Ó]lžīéķŠ}¶[£ŽwM‘øūµŚ­Ŗõf(ŹN>ä'Ų[¦iĆK¶‘鼞œī|Ź®Å6öz‡’ź®c˜Ūs“l¢2©!“År€«©™”ęŌÅÅ8ŲŲ¬ŒøøŠ”sU“ėä£KēčĖ“72ŸžC£)ˆOĆųP!›É¢#ĘqüG Ó¶éčéq:\|4“Žż^½M.™äoµ»±½ZY6’v»]¬ō£1 īĢKf¦Ē/Ēōv»¦ū›Ōk“W}½Žc‘źŽ{Øy]ĖžŽq•c”ĘS?ƒĮSÓēź? !—&f⼩«³Æ²˜UŌ½¶6ƵJ3Q©ī&“„UüŒ@‰éóæąŌįt8‹/Čń‘ćåĻUOō–·Wkp;·äv¹ ­Ŗ]*ģĪĪ]‹gžÄ“œ„’”¹žßæhō’Ŗ:Õ µöyž«ēz‡šä7‰ü8n“?ŒŁóų4ž_/Xg}¾Ī 6›†öS6•uöįæžŚmŪnūVŁ­©Go`Čņuu¢Qk6LöCKY5øóM©KXōĘēDu:ĆCedž†÷'¬§JŪvŪu¹y¼ßŻ™‹Icä>B<Īæüūė.Śp­Õܟ­¾CØŗƒØł¾[‘vü8\±qa±|š¤F»k¶»k¼Š²¶Ļ>ŽCŠ–öJNĖo§Ńi:N«±žŚrߦÕ,ō„żżå‘UOŸ…yXl%µŗO7m…“Œ'Ń ĆqE“¶$Ł’O:‚’Ž‹Żčĕd§Š¹b|z2¢H’:ėrZ=źīŚöėæżE³Æ¼ū»<ß/Ō<Ļ7ĖVæ‡8p`ĮƒK%‰”ü®S’š`Į‰ƒādń?+‡6>SžG%“Į‡kf¦C9l\ÓÉāgBēšŁ”üžįĪ9¬¦681pš?5ņX0`üžĻéü®ryĢg?Aœ’'?’ÄO ! 1AQ"0aq”2BRb‘±#r‚Į$3@C¢cs’²Ńį%4ƒĀPS“Ņšń’Ś?wī%;‰NāÄ§q)ÜJ›Jwī)ÜSø§WiGŠu6§qNāSų§q)ÜJwī%?‰NāSų”ž%?Šwx§qOāŸÄ§ń)üJ‰Ä§ń)žŃOāSų•‰OāTN%?‰OāSų§×iOāSø”x”ī%;µ8„DiŚSų”ī%?‰NāŸÄ§å?‰OāSų”śķ)üJwŸM„?‰OāSø”ī%;‰OöŠM„;‰OāSų”ž%DöŽG#ԉȢ®“‘N²¦vĢ¢ŽVWGUÉČŃĪØČSP£Õ•õ s¶WČjŠ B”Č£‘®„ś› †C;õ&ØŃk£B»i³Rł•ņ BQ¦C;ä•õm•uƙĢj]_Vś—Ģk]AN®łTõ„]]Y hVÕøVB‹„‘ErSP!ԌĀ|†­õ.¬­©}NоWĢdz‹u\k]ƐĪčS2؍IYYs@gt2®”Če|ŽVĢÓ;ån¢Ł”Q¢‘‡ūHĢg{€\–†héĘ8Ķ«¾‹’šĖŗnvŽÓfżJäŌ:č·H÷’¢SĶAŻZ•Œ<ś‚†”µ\®x# Æʘ1tžžxo\ŒÅ泦&ó†L¾Š&҇„ļ7ü”ō”ĆYŽlV饎Ƅ$3ė4õŹś·źGUed+«umATŚ+źt(¬Æ«U}C•µ­3²¾G1©(ĮWÅc;ČX$!ŅŽŻ©\…[½Äm°U†B®Œ6Ūe__¢yż›Źm $ų¬yļ:1_ wYŸEDq<ōC_iī?šĘ]üEˆ<ÕѝóO&ī&¼uB ŠSaR< äōNIāŃZČģxLćö²/=Źf^j,ĢęćAqdVpsMÖ¾wČuCRČõ7Ō*źŁŪ+jj‰*ƒ;gt:²ŽtČee}Y)IWF˜ˆ!Bn׆¶)l«Aų’ §Dg“Īč1€5µś¬aŃJw=ÅĖyōĆiŲ±g·DĢ>œGzN'¼ärŠRŌ¶@ ¬U”ŽʉIČ.Ńt(ƒä:Y8ė¾a ½ÜMÄQ[­箺¶C*UB5VŌÖ²*Łß.Šŗ*Če|ÆŖS‘G+j ØeŸ! ‡ =ÜS˜¶$ē[.ÓH÷hńļ@fuH šZmuEŻ»v­‘ȦŪÄ#„ݱCŸņ[*C]ś5„ģ)·ø¢Æ­|Š*ł‡SÜ(‚ ®‚²eudĪ£;+õP&ä25Չ’Ń%”Żóģa*VN„E“‰öć9ߐ§"±YüF¤œĢĶL¼2^1¤÷¼ģ,^Zz$“Ä“H1į=Šāˆ4،4- ģ ¬V#½ŽōĮ ˜Ń©Ā–ļ\Ÿ…!GJ$Į a&­¦ó‘¦øż{yōƜ1Ż·Į|ņ?¾q™T µ2ķ×)ČÕ[Rł +j…Q•³Šģ^e Łk~<¶]Ä֐ō?øHÖ9OIĪ@š•Šéy™gĮŒĆG5ĶŽ åŌc+Śc=Ī|Xō#ßÕÅÄķ$¬Nb&”h„ī⣸]ÄŖ«j„7_Åķœh„‡.äb8ŅG†DūÆ²Ń˜pń’ȰA Qāu‚#Ø3UU_P«ęu­‘®Ng)'įū]&ž ŒL6¾‰‡³żBéelÆ®{ӎĄ£<śA¾* ½8Īīƒv—żēÉa¬,7ZöؐfįgBwųJl’'0éąkĻ˰øūĄhŸÖKõ¶źœŠ(¢ŽG3™Ö¢¶ÜŠ4ėĘBš†9/2F4=÷˜€|ŌzĮĄwސUŌ Ŗ#½8üū¶C§y¢›õžŃŻt×“ą  ÕļLÕUV\Ö xōĮ¹(­™Ģ«ź6‹ģŁŲus…Ėœ9ē”= ­ü.—Ń9Æ-;Fč«ęQWG3¬QŹś÷ėEÖ:„m{kŪ ‚¾A …sœžœ–"¼ģ'ßE3ĻF‡Ņ—‹JpnÅŅˆæx²llĀƒ–ÕE|źFF›[UWBØ!MØQs’ļonšŽĖģ*Ճ0Īpl« ”L/lFzZVWČõWŌe|^5:›õ[\Sز©UČQ~ņ‰KSFĮ.‡ÜžU–{M’¾”ŒG—?ʇ¤Žö(ré+«ål…–Õ|Ź(£DIE‘Ķ7&ć>H0yŚéE„Į7{:?ö§@*NĄ)4ń¦ęĖ1Ūß·ä¹/1iŒF6—øŠĀęYY iĢqŲŲĢĖEå“°13+ēR Ū=-öŚ=ᵿS‘E]¤Ó+õ÷CزéuöźĘØŖ]]ŹL3“=…¦÷C’B£µ‘HoŁ—9­w½M0›r’J6ʈ”Æū±:?š±[QŖ¾BČhäAmW9”QE?’••ä~+…Ģæ #C0ė±­Œi„šp t=āÜįü“ŒJ“~( „×AcŖ F—ęā4=ÆspAÜT¦)”|ž”"Ž–'‡3d*’öxŪrP ’¹][\£EŅė]õ”®Eėι čĄUņQ}ÓŃ?UŪ^6<\{Ģ’DY#FŠz'Ä&ĪąR“CųššćßKų­«¤®Ø€ĪŖÅ .Ɛլ­}“UW”n‚XÉČf»ĒI¾!ŅB®æ¬Ŗ ķˆ:IĪ‚Š6 Xõ-Ašc0D‡„‘!›“µĀ„9;åĀ›^b M9BwĖÅlłL쯪2­™ÖŗÕŗŗ¶”ŌAS«(ä:²Žµ³dę 7(ė‰ˆNgĢ#?4ęéUuvP°ŃąŖDcøŠöŁü“|¹»¤£9Ÿ‚'L}J*ŽT9_;!DÕ}A˜t77ˆ¢|¾'(±†š~E~²č@ō_G3¹×OЪ鶁E£{7ØĀI½®†‚„9{†ĢŅ˜’Ńż(†ŸāŹźźł„Ö…µ  +~²źśĀ¹ŠfŹČ+ė[Ŗ¶­µŠ¾ ŹŖ…XMKD…R騔'n„Z›w‡-)0w°ž'č¹®SLŹќ¤ŃļĄ5’ Š(PSµ mŹŗ¶ÕњwmĒŲB5Ŗį\ä6+ֆģ(8ŲY}ƒAŚÄkŽ”Ęåü”kę²cHvÅy?@†c0Ѐހ&č^…æ®:õöUUÕ:÷ėB²S!©P„’Ja˜‘† s]»ŽmǁTœ˜…OJŗˆTx£#Źi ŗŠBŒŽsī? ļ›Ct.…Uó(£ĒŖ²ō]šF.ˆBmLiM †7Ü®‹¾ŖĪÖō¢¬a}›”.bēh·z{Md7ĊtaĆĪqŲŗv5Ė\Gõ#Ťģ™Ńg€ĖnaŅØ  7N5ŗ7ŗ{æv[+!˜A J ‚ÆæPóžNę^_&ęĒosMąVŒĢ€\‚+ĒFįĢE`ø>‰ģ7 Ńših„ō iżįc⫽TķFŗ§;ė”L£»/ņBWŹ<“\źCœ¬¼Jģ¤Q¢!Źœ;cZķ(}Å9Ļeū6‚\-dÜ+’£`>“x­ZźmléŽĢÆ©dJŪt*nŖJ$õē\ƒ×Y[;ēE}[žąr…5…ĢK?Š ŠŻÜįE1t‹ól4#hē!Gx]&žŹĆž‰Ģ–|/aÕĪæÕæPuĄŠ…F“ÅįÄ|“PįŽĀƒß„ć’ß³Ä!4¹ĆŪ-üŹ1™ ü×E¤Ŗ^NEóHd(L/ˆó¹­*>;ŹÉ™÷TCqŠ•g³¾ł«+źP@+¤UŹ«ĻU}QØõ QČē}K”Ō[;õG;åŃŌQģ.Ÿ%CĢGŒųŠż“%M|h¢.Ņ„z_‰E“]ł_\”S“‘GØę±BwEߒŽ’G‰ŁBŻ9ĢÜģæF “ņ=ÉΚ†6ģØL0™Ų¼ŽA˜³édiĪ‘ŗęž-`¬¬QHé|Ž©G#«|ĻQn¢żH¦”ė(…•õ )Ė„‡Ļķ@yžał§6r#wFżB£t½“]BŠ%‰G‚¶ÅMʊ‡0«Ÿź0£ū7h»¹ßž(r~R&0˜īżW‡G“ģ”žGJ|ĮŠ>Ņ BĒ~E†ą¼™ˆĢžn¼2ēvÓpļSŲ®?5ˆLŚyu=–ś­ų Ādh¬WIŹå][®·Qn®żM²æSm{+źß/:ä$å_/HĢ’¦oąæSlJT¶¬x'¤?š&"7qøīuŃ0ĒČüEuRC‚Ų›MˆQUu|ĀYsŲ4Ģ:T–Uæy·Eśʆfb»9øOpłŅ‹Ź¤É¶ęąśŃāC…į¤O‚åsčfń)Ia½¬#»čĄ¹9†k™Žwˆmdć¦W“hpt|Ś3Ļž££?K;”|īńåa^<Ž+ķ6„Ś +EkmĶD«;«P„ŻųƒŽ9)¬–iß ęéżęÕF‹ń";N$GDyŚ\듨³%…}™Uk—¤8B‘‘ĪŁ˜Źł[­5Ģ£‘®F™ž ±ˆ:–Ö9 k£©|ŠĒꇼŅQćƒģ1ĪśŹ+•Ó¦d&% ĶŃķ‰į³IĀ„Uв õ h¼včmŽÅX7Dæp­± **4Ą<7®QG§3#1qŻO™\®xRāöhśUb$}“Ō6v49’Z,#ų³bv ĶrA›eSżcœ’Ī‹—żŒ¤]­†Š~tSqć6 oöd(`½ÄöŌ®XLJššŲęÄNj BŻ——mu(4šEM¬±ČLĢĒ›”ƒ ^™i1łŲ ƒ§ĄkŚ@y”½·¬; .gńVĆ|ÄxŅ䱬sų:œē8ęT5Ć٧y²•‘å,ō”9šäć:ˆśUĘŃ.¶āEGbkŗ.MuœŽ Ø0qiø0ĻŁĀžė^@@„$Č£ų­æ{l°čŽOqģ vi’ō”–tSF‡¼‡2ż®e>+’LkbOFtć÷Į†t!ŽŅ¼œ 3ō,©ótā¼’b0Ćba0įzŠt”;łJ—‹,čܟŸ:{D”ŽĆŲ"Ģ,s ÅbIb2Ļ”š…éBˆ(iÄq” Ŗ¬Ŗ Ūew*F*«³P+f5iŖs(¢Š(¢ŽA ŗJČQ5Shø"V:ÉRöMM°¾·‡pJdĆ@҇±ažéiéhS²±…"ĖĒcbĆp v<ŁŠ tŻ4Š‡b²šĒ1Æ7kł˜ƜŌzWE›,8Ėɼ«’ lĖĘŲ³ŃIų£ą¹iČďÉĪO1ŅŠƒ¦ŲŠōÅōCžYSܱĢ3#‰KD“œ—4/høVćąwŹ9¹˜š`ĖhĖsYEs%ōbD4c>ŁĢé:–nұé\Ļ¢“ µńe@ˆcC‡Īc]” Ś¹“”u{‹‡°ń8Q#QāB•§6ēˆRl†X^hķ&¾„m«zy0ÉvNĪOsSDDa”‹Ÿo^3ö4£ózڧjä,¼xMd”»å”OKL6tgÉG‚|ā ¢3łV«“RŲtX,o>žˆ Y0ŠDUõŖźiNćS¼¢¹Ģ?K|7Wąl¦dńØŠXņĘĶC”Ņģ!Ķń Óų¤g>®{:_xXļDzŃ6¬£­½5ŠŲkU‚r»’ī6 ü ]†ĻS„ üįbøv13#9Įš”ˆaG„w9Ø×"BØ6N &‹Ņ²¤c­ulķ‘ŹŁ[*ęÕŠfQĪ™ÉU Ž=aEQĢźyÖ/*īģ ZRY°!Šq)ń`†‹·œŁņQD³j°\o’³øN#F‘Ä >Ģ#½‘-Ś7)ŽJłZå2ž6ųzFŚlwIÆü@Õsų »ÜÖč?¾GņVP,Nńt”»šQĆė—!±Ÿ'ųN—ĮēšqļÜ^j fDŠb¶,6æģĖŗTv’…$&ńÜ"G ™‡ˆ?’2ó،§ $GD/ k½`Ś÷^Šaļ/ż+ћĻ„~r3#G”`d8„ŗCEĀū6ŌÖ«•Ó2Sńē]Ӝci/<ęéhéhiō“kJīSn„Ń+ÜĘāZE+Ą.NĖžŽz3Ą½µł ®BĄÆė/ŽxB†ćātBĮįęŲlhĒqˆöĀk–¢VJR[vœgx–åVbµÅŸV!Įń Ɗå扛Ä&fk·Š÷‘(jĻhQ:_ƒfƒ…S|Ö,»A ŚmėŌ>m§oa?惜ݒJ§ó]#_okYBlԟ)%ŁN|‰LF›Ža<ü%ŅĶ„„Čō4Źŗr(«ä5ķØz«dj¶!M{!©ets¶½µNL–“‰=t¢t`}ŻåMLOtn\z-No4°Ś¾Éƒrč&Hłm±Čm£1ģ4njyógłÕŠ›—ö\"“v_ķšCSģ܆™(iŅŹŗ×Īłß;#\†c1Æ|3:ÆÕŻ QD™Ä!Ąo®zNąŻåJƄŃXPʈnÅ! ćE½'zŪQ›j„[ nyߑĢkjü'0^ļź§!ū”…ĢņšvL5Šßé‚²Śˆr¾a:®“š*hĮ™Į&R‘Éš B>é^—*! Ņī)Āf“Æ_˜æKÉ4ņhčš<Žāł‚ł”L¬€seĒ I]<ķ•Š¦µõ/•ņ=hŖøVÖ(źS©}kēŠĪŌti¹ ļ Ą(1æ£Ļ-Yџ”ęÜO܄]ł"Č­4ż›÷zMš+›|x[HśŚ»ræī™>õ×éæčĒ‹į†˜ĄbsšĘ×čĄw:>"(Ļž‡¶•Q¢Ė3ģȹ6Q'9lĢæUĮŚ4ŪøĢÅqų6ƒ “ZĆt(ź9ō‘sŽ­MA ėūÕ9QV ØSؾ½ŠŖ ŖŹ»Pa“»Ņ.ŲÖ=z”;r~ŌÓb“F§h1Æ5 vˆ’īѬĶÅs›x£†Š†‚ŻĮKr{ś/ć2Ā l÷)40¹uééF=Ķ„×!XFnČš}č}x"ȰßųOÅræZQȈLCńPäüØĶį1œ|ß—:p÷8C؈?öœõ+)ŹŅ’±ŻšŻ¢°‰^O¶#hÖiDyÜ*SńSāī5óɘ‘Aģ{Ķ< ėE¦čW]8ŻUŚåe\ķ³÷@‚!•G!Ör²©Č “%]M®čüՅ>(4imvʅ%‰ļ!0 ‹MŌpŖ`sH½©e7•øńRŃŲ4ĒHlvõŃuŪ¹ČmX„’–˜rŒēJą’P_ŌdI°bÄu8žÉܘ€ā+ęŃLr(§ęŸĶÄaō›õjØźĻQ§‡Åōj;Ūuņ›‚āŚ"Zeœéž­ĒE’ŹW'q9ˆxĖ«ómiŠZz,1Ķøütj `ŽO…Ź}œlWģ6ˆ »ž{’’QØÓu“Õ8“tē;1“;T>Õ·ĮBķP»|/yAķšPkėx)zśŽ [·ĮKSÖR¾÷‚•§­ą„½ļŽšP{|.ÕµBķPū|>ßĪÕ ŽLķCuSiæĮCķPūS{|ķ\j›Ś›Ś™ļx&{Ž o‚‡ļx(~ņg½ą”ūŽv”]ž ©½©½¾ wØ]ŖjƒŚ öؾ ^»ŌošMķšL÷¼>ßŪmMķMófz^ŸäQę7öģ­¾ˆŚ”Z›> ¶”ϊ£Ģ²»wUCę9č¦W£ZvƲélC›Sßļ Ź’>®—?š¦ĪcĶįsTü*[żĒ““¹½z;+Īét|h„JLSKҾͻԾ‘¦•+mŠ½ą„żļ/ļx){Į@÷¼Į@÷ü·æą„}ļ'ļx)/{ĮIūŽ WßšRÕõ¼·½ą„=ļ)ļx)?{ĮIūŽ [ξĻKŅčlće‹įéŪsp}-ŗ|Ä=/žńXū} ž’—óVł6hŌé|j„tN–ŸĀŠ^§ofÄŻU?Dś^ õ>—‚nō¼æ½ą„ż’-ļx(÷‚—ÆÆąæ’Ä'!1AQaq‘Į”±Ńįšń ’Ś?ź’,æs,×äbœž`Ų˘ł/Ä[ę{²Ž’,°žĘķu`Umu™AżŒC·Ė2ē±–s<–ÅtéGīe?¹œæ™oó3ł žbƒC݁„łń•łb>ćl®ģåøęŁS™•vłe­Õ‰ōžģC…åœā­e…{x¶Xµü¼EŽ,-ŹłfڶWŚü°ä>XÆ·Ė-汆n’.§'ęb7ņ2“–cs._°,Ēö?ĢūzŞ®ą•ł`[ģ`ėü³?/–Ėü°’/ó?·3ūsÖņĶ›5¶f`ģ<°ēĄŪ?™™>ēłŸŁ™[īa›,ÉżĢ_?–(d÷aÉl?#ī[.~Ę$Żģ姖[tsl*éwegCYeĪ—6Χ~XÕö1.o,37öweS?#ß±Ø)ö2Æ£Żž¼³61wgéŁf¹…’XtÅÆƒ»ÜĖÜş76ĢüĢ÷2‹ūŸęe<²—P[ÄnPżÅ^œĒØ|Ķ„Ō\³”œ“²ĢŗˆLB\>åŲüÅ1u2®1r”Ęž Ž`µęŒE-¬W¼¬ćŽ)ŻLwQö73įRauńnuū™g¤•Ėƒ•mīć ¹ŽXEMc“¢ŹŌ¹õNüF WŁ%ŲÖŖ2šÓ?¹@ā „A³S+šĻęGĄAä.1ˆ9A Kt˜ĢfÆõĀ©GxCL'‚]ž¢\K`1Äuķ11L·ūˆ—OxĆ÷¦± üĄĀ„* ęR½„ĻhŃq»awŅ)RÜ ·¹AĻ­{1ÓźeKŌmļč1Ć{ƒ ē¬Gŗī7h|/Üv’3 ”"8ÜTFT4ŗĢS­GŒ Īƒ©Ō©m:Ī.Ū€ø‚hĻHE+4„,(‡mt…§p©Æxļū[“Ś›•ÆØUU LC­TŹøƒłœHÉkćz”Bźøķ`oł•7Q^Ń­čĮM@§ĆP„r­Ą­{Ģ,ĪaŁūˆÜĖ»„¤JגgH\9„QQļŠ ļażGCÆĢWĢ-8„ŪĢ öūĘČ.£Fź%8‹*(Ģ°ĢŚœŹ)˜•qŌć’‘µŗG_Óåņ¾ģʇ[Ć(„¶5Ģe<ŹSųFŹõ˜zÄÖåčģV SąJŒJĢC@IQŠØ—Z—ūĀ)Ŗe3Å1¦ uÅwUųƒ½Xc¬N&„‡H—sØ¢éÖ·߉—Y‡dÉé0kz…»÷‚ĒāQÜĮõćĻ¢:…5Ž Vī® gżØ6śh½AZµ3 ł‰ćN”k›–™V‘j*²L8÷—®ńGźÄ.,ń6ß¼·@Ū5‰ŽV ļ¤! %~ÜBB·“H>ńŖ`hUάoĢĢÄ­BA]ŗOśJŸp3S!0˜3 XÖc{ ­f™żK’nĆržč7:kŠ'M¾„n£WŪ”8!Ż-y"ˆ„ŖĢ ~åéxīܵ”³Ö ēq÷Ėo”jF¦x+ŁŌ…,‹gH‚į~ŠŖ.ēó2eĢ>eŠ]‡1ķŅt£|‘łƒ}ā-¾Ń-<Ģs×l|§sĆ.µDæ±ū]¦Vo÷āćź›äŒ³!;&ŽņŻ*Ė’ŚcdUV÷ķe<-€/Ś²ĀŠfSāXėc iÉĄ*8e¼A¶‚ĘH¼™ 7*„č2“3‘īĪ ŗ_XaĖ/‘Ā PæÄ²Ą\0ėļ-&޳0é÷-×L°&Øö¹JaT9~#WŽ"Åō6¢±†1ME»»—ßX¢K%ĄF F|üĮžąO¬YÓ ”×^°J»‡}}kŽ>šBy‚}ćt„į3Ų%Ą<'āeó¹IJ’nlø¶ńˆ³Ā)(ęX?s1ķ){żŹ!˜Į3YŒŪ@‡ū0^/sUĢĀRf_ˆrǘŪööܵu…̰ī(ŸǘŹxó©WQīĖĘJ°8ĮQPą:‹“haŠó£Õ§ĀpŗüBR—WdŒh$<żöĀ"]i²Œß–4Az#õ,ģ„—•F9¬Ī»Ś– Āó–Ė ŒL”Š-ZĶōyĖb!Dóo{D–Ū¹rCKÖq?ĪbĆŅR±Ģ.Ÿøˆźøņfvæ‰yž%Ž8ō1ž£\Ż/C8·1Ÿ®Žˆ¢&ZÆā¶rŸ·ācĪ&ӑ0{K«]å/IWéQ!W ĄDaƒ¬&"ܰęSL¤ėüƇ¶ąWx‚įMG_Ź\ÕĢ7¹Bß¹£ū—«˜žÜĆ^Ū‚8w{ĢŚ,jŁ.j#÷ ó(ø…Ļ ü@ZŹ]>&’1·•Ę ļ¼ÜZ€åzM züßO¹Œ:|Z*(+~¢o©.{Š o­ß-—)hĪõĖ»Q¹–bļŚÖśG µžĆ™¾ć Ä V¼ÅE=ˆZŽcz÷¶G²b&Ė3Řå[wœÆrĀ\?w*Ģ6f™CP7XĒÕēŚ į™a*;Ės 0¦XĮ‚3/™k'üūK¼’¶J_Ya™@ō€‹™żKBZ&289” ÅįŽƒŻānŒ=‰Žõg˜/fn•#¤gÜž`Fl{LŽ&¬k€ćxÜ[×Ģ'I³“<õŽ÷BA ßł™«ó)ųˆ:ö%o÷·'¼yxHö¬ÉśƒżGĄā7±¦ZĘ€“C„uXEyeÕĻ‰ä‰ˆÜ?ę-Ōr²0Ń¦š§ˆ‰Ž=L÷/č­~£U1ęĢ£+vb¼AM> \… 1d;Rķ«żs•ĮØlÄͰƒ„3Xę¹ci}bĶßxćrɅ±1gQ Ė‚’óІ0aŒ`gŽa»—}N¾bÖį\łēżeÆN¤^ż¢M÷.ØpK¦bč_0”3?ĒRµźn³ļÄā’ ĢņAĄ‡ō‹wĶĄėĆ÷* ™nkĢæPT€b†> ēźfĢ|޹‚ßņLŻ”pʘŪ߈æž9‚ødC=ś@»÷ō®4V$m„ö—3Ĥ—½āXļŚa¾yg :Ü'ł™—¹Ģ‚ó ½¢D…Uӈ«N`µP|0ĪćŃwų‰ ŚTŽ&õŽūĒG‡N±Ō{ėÖ]¶ Y4y‰Ng_|K[óčĪ"Į)^XĮ¾&7³eÖžó8qķ1-€5 Ÿø²·īvÄĶ“ŚnœŹuÖę>*Š„žå¬B/ō„4ŅęZGį‹r4Ż4”A©R±oīw¦ ĆėmĮ«„\JÜŪŃż¢ŖęŻ”¤µĻŸ¤A”ø„ŁŸiøäĒÖ½üĘuˆ=ćg˜%“gÄ"®†b“Į­ī*ŸŌ³w]„č“7RżÄėåüGø÷ø%[R…\C~cīé8Ń,”T©ė3:T«ø°õĆĀüE–w fi—ó}śĻ‘“…EĢĪ*`«ó‘ĶBęz‰Iń±ę zTXTrŠ•īBh-`·Ä/bQ"-éŠē1«^ņšUŽ”+ˆwö™5s>¢īŲ({Źųæf|“Mum}Dł Ą‹ūČ>(C*żjæ-±¶pČL ūĘa<ßō@µX<ƒ½±õłpŪߘƒ)ūސ‹\A»Œ¶RSżŪŠŪłõU@ן!7Ÿā$īA‚dˆøk›‚Cƙt»?PrĮnXƒŅÓ|ne§QV®­°9\Ą6ł×ōFĢæ,¶R˜—œK¢Q¼Lš€Wnbnaš&æp~g°Œ)Ó/e‹/j­•wFĢ~Zi!ść!\õ…Z•Ģ ć<Ā€ ūE6=ź_„³‘¢’57]ŲÜ“ä_ø>“”PV"\Qˆ;ŒÅߘND„˜7žR1Ś]pś}ŗĢi^Ó4Kū˜ńA®ó«ø7Q f(؝ xnZĘę‹ōNŌ1£õéžą!—„%¹fÓ¹1żF¢ĢBŸÜ ķ ?Sš- |Dšųęt‘ˆ¬ńąīe%=„V&ćœĮ›aņ•·0n"ī)Üó…ÖW2Ē·ā?s?ˆBA˜pN£_pƒ­Ķ„Ėü’e·Ä,÷Łxų™žaįńJ ^ĪßónSʼnŽ)QöŪÄaĶö cŽžażT\Ī:ūø'¹Œi€N zĢ߈£©ŃE’ł€•ĀŁ™uf @8H¶žł rF٬€ü —pLŻ5+8ń2Į} G¶ŽL&U|L‘;˜’«3˜Ę¬ōƒooMH»ƒ¢aÜOÓŃĮõĢļĮ’RģJ™¼Äö—F¹ÆŗFS¬lś‡ZĖƒŚ ·1HY Š#¢ą ÷)ƒvö‰ń·{ų]Ü?ŌzÓņĒeÆ÷?å7?wįŒĮ(V¢ "#Ņ]X–A§ūŠ­‰^‘ęPÄ0&ćÖfŃ 7|¬–‚‰Ž ßq¼ s(·Äį3W°ĻĢÓmµ½(Źrko¾SüA’ǬTq]į•Żźµ)kıuGH5ß©Ļ"‡X™ö‡żĢl«¹¦Ń’°zYkŽ!Ža!æŌ Ėśu‡†k3‰qcĢ?©`&/FDźl”‡ń÷Oę¦LDCĢN” œūf(ž¢Īæ2ĒpǤĖWēČ^·3głēӟ?1ó¬ĘfaŽ~b‚/7YüF+ˆ51—qĻßX>ī!R3+Źt) R㢠A›”Øļ¶ÜĀ!Qo Įœsˆ`?FŸ0į©»’•µ g}œīėlüĆ[©Eé@łSŌ‚R&Ę[@")ÆļĘbė+ŚZŌ«ˆYÄģjmÄrŹŌUEæK a1ŌŠ0^Ä„B¬Ė¹–Y;BÅÄĢŌ-}o¬k_pwps iēpÄŅ÷0‘ŒAł•®ÓīRxćŠCWÖT±DęüNS ĄŽ—>%®żōßs?ŌMĻ‹ŠSŽT÷‚sįšę.żex‰¹glöIP2łó„yąü ”AB6bŪėf%t§ó©‹y°cÖ…d2É œJĢĢF`¦ąŁHĄ¼śbé2jvfWŠ/c† S1”BĻ,łÄCvėź:–ج\­haG˜ ®(ńɏh;Qøb(Ā}o¢•°īGØ]<ĖŁP8s˜Uu¾b-KļŚvj®i qj+RŌžeķ€ƒ­Į,”uŽ K\r&Hq;=6ō ˉ²+s÷żKS :Ō±²^fMLüÅß„oH(_S§;ŅóØ•·ÖnZÜL0`šĄ_¹‚ )ż9–”Æ}fĪ:Eˆ»ƒb”Ęc‰Fe:@£õ( ±€Ž® ŗq!,±MŻøčuĪļŅ¢¹…9*üįķ‚cÓØę!“¦³ 7 ó/³‰YĢ5Ź^į,G¤R·ā"˜ØÓ|ˆ¹}øB8ˆž~ą5܄ČQuy‰®†éŠX‘ŠŹÓaJ¾¦Ōéö¤Č•~m:HŖZp‡x ę œœKT}įœˆĖńch±R=v—­āvĖĢhr-čŅRs³ė¤<+Ū“ ղ攪3)Ģ6‘o^`ā8BćˆĖŖ=öP¾…™Š`‰˜ˆmé™…Ff_IŠ=BŚŗ‰x5ļ©Nе¤ķ=åjßN Ģ1Åń bL‘ » ÄE\ƒ×7ł:kż}†„Ä<ŰneŽ9`…H^9ń2ˆŽeŒEŹŌ((uęcÆOĄæs÷™…½Ż—ģμfXyҘ)‡”~ń„Œd`k°Y—.vŃsą™ó \_R®ZŗÖŗFE2ńdC…Ėā‡h*{ē„¼q0M³ĻØLP—>”t‰Z‰‹p„÷™73僌Ā>#³Ģ5ž …ADę”UÖtQŖWĮŚiØöKYÄj ]J8”_F(ź/“ȱ‡ys9ū‚Ō?ˆųā Ö`:ƍ2ø—gĢńżJ#•Ä£ū˜“BCˆmgżŠörD‡ś°˜ürłĻv<øFķr×0yG”ķ‚7R†ż¢.įc3<ź1 Ļ;޳4xO~Él 6T=‹{F Ā›äļ#D Š$5(¼G&<0pāˆ•č½öąsFø>šĆÓŽ\æØ™Ģ-ė =åļóÖŽX¶qd9–·“¬.“#YØ Ę¦x`\=`Ś }ā0> j$eiśƒ@±ÕŸi}fd_ū—RŁkŽ~ńR ‚Yi‚RźW„4q!˜„Į†`ĮrćüL›©W1+rŚĻ¼čڈQŗ§õ3‘ŗž?Ö`Ž-j.Ś/Xćŗ‹ÖWØ„ĢīĄ¶^¢_”łē'ÄjƒéŹTÉÆķų„±ĒŲģ½ŲŻXa|7[öšÄé«#X½ s0”­).-pła±ų…ā ŽÆ¤ €&ęUŌÅŠ‡·n#R~%Ņ’ĢÜÄ÷¦FmϼÉ7ęrĀg—«"_Ō G3‚żNœĖę.R6zC!A£5ŒŹVįR\µ–;÷™æQ±˜µI‚ *¹äōŁźU1’„rxó˜gōĪLgĢÉø4L{œŃlęwIdė Īįµ?”ļ ę4N±ńēqX~H)¬ŹožBĒÆļ2¶ÅH7\„¦¶VÜŪ£ņ"]Ž=Ÿä—W ±ļpyPkՑ"ĘØÅÄø¬ŗĮ¬jˆń……'gĒPŃ~¦%µŽńül½„–µŖ÷–hQŚEŠÆ@™Ÿ%ē ®®ŻŲ[AŸi’jg”Ax”-Ń ;÷™ņų›óļžæ+“Pæ_ö"Oyō’Ā];høŽI‹Ģ AB®¾ż/ń =\ZŻŌlŠø«Q?”ĖQ™„*\Oś™ć˜:ĒDl6–&Ų°Ń‹J=¦Yœ71æ8ÆŚX3āgWpąĘ¦»W[āy›o ķŸ+“N«Ś5ųā6Ül“°č²Š h݊Ķī‚l¾—‹ī'XŠ£;ŅÕ¹>¼[‰ÓĮŽ!LݧĻ1Bx—QQ:Fé‚yŪņK~;[!šY]¦rW%4ŌĮ°©›ø¹F{åĪ31}N~#źą²·ˆ-ā¢z{±Ģ؏XŖ0ōóā&ÓnÉ“¦ć[Øó/KL\žt`ĢY“¶g’ŌsĒ%žfh×;ÓÉļ/s)S}E¤iā{;ĻHćæ÷h5¾šƒŚj^¢Ūę$¢ŁvYś9Æ”³Į½NĆȽ£ŸÄ?§¤ēæHęRN.b(† č™—ń2BYB1.5µlTģŁłü”ĒžX#¬ ĻKn’Φ@ޱa‹9X":EMš±`½/Æ2ƒ\ŹSś†Ņ¼°{¦"YZŌQ¢m_‰M­{K®x"øX=6“Żž#œs[”Ć ½Ļ°ƒ~‚K†qĪ09Œ0õĢvD½M¦.ģ JØśłōN„ćŃĮ¼L›#i‰ÆŌÉKļ¬X~±§_K¦j*ī„ l>",ĢB»Ž’ĘļJ.7Ū·¢iõ)Øü@r³ÖŲ·œ]—ū5š łG¼¬Y½šŸø¬6|˜}Cє2į118ĮV&±+mz&CR* „.M²ł³ÖąĆCł1—ŃŖp2ķĢMzxGĖ[ü%Ycņķ#ēć"r.fą®]Pć9›Ć¬n¹{l?©Æƒ=! ļÄ„óĆ2v™­K?™{Ģ! +õ:»PY\ŗ‰P¢łÄÓ¤,ā˜†x ”Ü}&””7ĒŠÜ"óéåķ4v—‘#V³”bŌ½Qģ’f3dzŌæHŖeOī $ņŅńś”2KŒ@*gÜ9ˆfåfåV}¼Ą~Ā ań µ.?Ģžä¢”ė•/ĆŗL%øŸœöy¶|ƒ;pł%MCi-jV}¤RćӆÜĖō€CVčęTyÓąĻŻ+Ó zØ]8šęP’Ūßy“­~Ž_ńC|X{œg1ŪŹļ'$ͱВ$o@š™ö+ģÄJ‚­E ›Vå,Z^9ƒU'hK×,`1ÖWź~€Ž§ē™üj?sŠ_ÄŽ5X™½5ŅīYį1KJ#„»…·˜-µ(×ŅϤWR—ŽŅėżEō– ›¤2=&sš†ŌMÜ1Üw3åDń>ēľ®+I­UÄ­\XVąē“9zę%’™fkr‹č$«Z—ø+„­FÅāTØ6JĪшu¼Ī}ęn&ŁĖ[ō žcuĖ:Ƙ!Ō‰žÖ\2tč)¢¦Ė«ę*•hAhy7Śҽž}L8Zāq£.f~ŗŃ?łJi;‰ģ—ā £åg逮ēō§ł™±\ž)„®]5š²ß˜öŗ›āčHŃĮ°¶ t…BŲĆ "üžļ*ŗZ=,° ¢”Z÷żćPвīa„2&LNˆĖé«–£0 Å+æōHWfKź”ļ&r 8GŃ+-āŗ«XBg kčų‡²EaƅéaŽ*Üē&“"Vž|°óĘccr¼HŲ=bWWY£óav¢«Q÷łō×ģł7“ƒŃzxŲ¬u‹¢bbT1ę`t˜·ž’ŽÆ¼žDĮ“?qźĆ„¦ –ąōG%ęe11j0—Ž{š«Rŗļ)ĒOÜd'ÄM{GXŌ †d+Yn`ńW˜Ńńš–Ė"ĢĶ\"ō®· ¹ÓIŒ1 Ä Tv–åĶ,¶ į.Ø»;c„…®£’āZ‡’£AØ_Ę×PJšY·TĄDÕLD bXš²ź\&¦²œ Ų(™‹‚Įy’‚䁣 `:޽ 5åŖ#m†˜P%ŹœČbÖ&hÅSĀ6Ż8*¾­v%o$pĘ®õłŹņ%C€Š]Fo07eŠ·U@SĘļYĀŠÄš;}·ę_š˜j±¦‚ĄUåxZŖŃ“mW*±CpČä_’ś–!–Ōų$•ĮJ®hÉc’a€„¢ųa]²n=„©i~&ćĆU,s)Ǽd™×Š‹é‘Ųņ#Nŗń-ĶLĒ>Õ”ßhąźĒ1Åž#R9–_!SdĻ}= `B%ž”Ś\Œwh£w®²Ī}ąƒ˜ [į1÷py=Ō:PWU ā ˜ń] Ä$6Ÿ©`2ŻÕTBF®eż§HhØēØŌÅŅuŠėāЁøė‚9šØ:ŌCˆžP—}ĢŽˆ_KųØĘryx5©„[ĮTs‘üĮ ®vŠĒ×iwAc‚«Žmž ń9÷aŠÕŖŒˆ&HÕ‰²¤< öc.Č\ÜÆĄeäƒyŹų?ir¶|N*ÆEŹĀ‰8ä9Ą ¤Ć£QEdžr× d50TU‚žŗ*Dj„aP`+F#m;i|Ēš‚įGģžHP3m}Š•āpĶčądh¦…čłs?–ĄXއ”±yżÉĢ)ŖöE¦ 藜āž£° 1į“ĆE)k3n|KTĘJź"~`ĮŠ=™,łƒl ;*ŽT^ŃŃjź9xN3Ć0¬S~a…dæÜV– ž3 Ü¹1MŽ" ų†Z§õ&bžPq2A_X¼ŗ•ķ Ų©^…āˆMS¬Ą2÷Ž='%¹Ü¤”®Śø„0GhÆĢ=Šž32R÷””ę l#5 Ģ{Ķx÷”4÷‡Ņ^˜œ$ÉĆŅv|L®=Ø©÷ k˜|KżÄ¬!7p\ABk?ą˜ŃbŻöļ¼Õ*…LIvWū¬"ęy“Gŗ_ijß÷JžŁ{†S%æNß`Š0ņ¤žAķ+WņɌ…čžWó-†škżĻš„<ČgäĢt©ßؐ£@x˜ÜĖ®XvĢ„¢Eļ•Cģ‘«*Ū«²~f‡4 jüiĮ¢ä•šf uĶÄ„·ėPĀ*o]†ŠźZ_H“[y ]åłL0mP!ÆhŌćRÜWЃį7,AŃY•£½Ļf!ĆŪć.a˜Xż‘“R±Ē¼KbXÄƱ/—å=²gōÕe‘OhG0p÷–J?‡ˆZaŃĢJžŅ“µ™Ū™+“r ‹žw0}w€¤’-@jµŅī:’ÉхZö€OĆé¹ėp½Ūˆ2xƒ¬óĄĖ‹P?Ä»Ž k2י‰O [<žŹ7 (É6’÷+„±d³OūSŒdV{cź"ĒQč#­›Lkņ Wd}/¼t`/¤M£•™ˆĢ”¹QĢpęy…lfIšAˆ¢Ģg‘o?rŠ»›­GC;G@lѼg1œ:O}E€,³,UYLjĄ€5Šl­ō=įß²ŪdūLčf”Ōń9`Ī/1`2|Ąz¦÷+Q ĆēęŪ) Qɘ£®”˜zßxGń(ōģbDøC† ˜ŒOį†ūK*āŹ\Ļū¤x5ŚœĮ#|ÄŚū€¶3ø 9a¦v<Āó;T}̉Zō"µ•521TŽŲ ķ*Ø67j`żĀŸōƒĘ~¢+“Øś‚ń˜ŁZ%b}=ąn~Qiن{i\’Ŗ&¹Ę8£~bIą¦zƒZÄŻFĻĆQCr„‘YŽs(7·ę_y˜Ł¾e™!Ōd›Īą©ößā:Y¤UfĢPh–“B #°D·ŅhĖAh w˜h€÷ßÄ!L·nZ¶"=ĻIa ļā[CE䙮v؛8_ÄźŠ^˜ļPšė łędŌÉu8jęM@K¾"Óõ4ā?I›µŖ˜."ń{ś+-^…ćĒē^"`£g(̧·VPŌ®Ē1įĖPÆ - ׈āįś!F™ŗ„“ŹŌ ×Kč3Öd.ü׿“äų‹Š"Wī?&‰BŖųbt’šyó0“ õˆƒ6‚¼|Š!gÄćŲü3{–Ą!ŒŹ:ŅŖ¼ęędŻ{\K„[¾"]”?Ž•Žł” Ż`č—åb^Ckvž„¤ę%fXfó;‘śĘ­Ēė¬īK½,“7£Z€śEf‚>Eą8į ‰īJN;'…X€vŠ«hÅKEh…UõĢĢC•¦ń·āZØ*-j¶Į«¦ac 8¬&sāLyĻH0]Ķõ–Yū¹ÜFŅø7P čŅćpć2×hw–,m3ŌZb+ŅåfcxŌ„ų‡_Øe`3q#ą‰^aB-_īZB1€/ĶBā¹ā Ģ:€~Šąćžµ-¹n瀞EŸć̵Ŀ0;""fa:Ģéę9Ž y…³‚ēSJ©‰BĻ£“ Ssr^’é.På£0jŻG)<1J­ ńę]ąßXJ_¢ŒhxØdĒzŌ Åā—ł”•>z–Æ4ŽJ}DbĖ‹×2Ķ£^åęåź7Xżcś6Å7ō‡¢LĖ'ų$FˆJ!`xĀj˜očā„ŠTŽ6¬tÖw`él¼K™÷ˆŽkĻ  g“”/’×w3vā*¾n7āø™§üĮ˜øx•‘H޽āRćEOM¼ˆ%ŠĘ¦I—RÖ?ńK&LEĵĢ0|F°ź1¹‡k—;Įõ0ožL7ńrŌ[ńś›%7dŖÄ%… Š&f5Ž-“Ń.¼Ė¹ÄĶæykc?IOīz QNf|ūL»Ø/2ŃŽ“] t\,zĘOVźe’ŒźŲd47©„x¶”ŖÜū±źÅāŲ)’øŚĒJ‰7œx‹ŖaWĻĢÅ\"nž+ķó£Ļˆ®Ē§ī0«j±Ņ?@Fm²<)Ö‰žU¶!µ¢ßĒä#/1Žć.ćĘOL5’æxc4dˆ=a”żGźT å @ļ0Ņ&:¢ŽįqS^ž-čĒ0ŪrÜ|jJŖĘ¹xėØ,ĆĻi\ŠęāvīŁÖ†āXĢõń(ĢÓpƒrłCÖ3‰WpĀRķł–µ(*ŗŅdÖ!VŗāP/śEĢ07/žŹ’rże= 5¬Į­ūK^" *ś”7ˆ1Š…ęVŽęśåĒX‘æ,=劯¼vŸ2ķĀØe²¶H•ō. ®,ŸL…eŹö™ė–%Ź%ŹŽĻ3 Sx¶daŒœĘ«6Ęė…•Ų×B@īŹ‘€ƒ£OŒKāgj;=å<\dāģ Ā©Š»÷™Q–‡nbš1ZUĶQĀ ä Ó"©]Ļź%—øĢvd™=3JļAQčDØÓ1/—!ާb)„…žĒHüŸ5Æt&W­8a¼ßä™ĻJ&¼]šO|”iTįŠŠpzĆÕwŠńQߓϹšęce±¶¹!·ž rĢˆq?Χ»ö™3,ߎ¦^¾#Iƒ§öōǜŒ%ē’ƬĆ‹(ÄņŽ”4KõŸėS„śĶ¼aŹFÖržüfļÓé,ßÖg?ŒÉ.ž~Ņ·ĖķgŚb?ŒĶŸ„kųAƒÕ0½Ń‹śTÄĆ„ õśśk©ē©·ų׌`_ž§ūŌĶłź{žu0ž:˜É}ī¦$ŗĶžQŒ®9ŒĆ1ćõ©[ŌøøÖæwUvÕcs^ŗåÄÅņźē"Æjķ©õ~óčNµūé1ś åRÖčęį‚®æ3āw3ĀūÜž^!Ǝ¤éן¼ī9ąæW¼óɏZå‘õ¾Ģg†y}é’Ēxó7sŚ|ź‡4Ų’3{ņœ—ł-}ŽÕ?¶ÆĢŌūßĀfmĻI£Ć5·Xį2杒x2‹;ųü§Ź?’Ä$ 0’Ś×s¢¬€V‰Uu"-)„q _źj~©wÖĖE­e¶Yr?×¾ĆōŃM‘"ˆ “»œŪöw[­Ér1 ’I±lč0d+B„J00’ż—-×Q1)šu~:ÆĮÓųZ?Wē…ĶüŸ™šĢ䂈(Ė+‚)č‘ h«QB¬"āŹ²’³åWy¹²öZł āŠĮŗģ6Į$nƒßŌ9n®²Ė]Ķ…ģ¶»&»ź½né™Ūjr± • †X±NāāŃłśWóś?_䫚āO”lČąō[¢ūX‘&mH=– Öö!¢ģfF 9Ō2·{XY®9'*ėŪ.»UÅ¢Õu,Į»ķ\?Óčö5©kXÖµ–ŁmÖ_÷[Ō#%‰jXczė{BˆpńpWćėšāz’Ißų LäžŽĢ R €Ö@ ¦+ĆlfĀä AJźl@Dl,ř­5\· >‹jZ]l{ĮwŠŪõ7Y`¼\÷›r/kś‚V i[ė‰r—  ŖiUbŒ\oąc1;Ų;%”·@Ažˆó، /X'@ķˆōKĖWE *T(P l›#4q^ELMl®,k~ėwҼ§ŠŚÖ šāAČ3`ÖR)šå¬æŗĄņU`"(žgā°,g'üo'(–°=i‡³ģpŸ2¢ ÷½kŠ‚3ŁcB¼• ‚ ĢŪGR2•tKE’śS ßö$:Ś×‹¬øŲlkQŝ^ŚD5¤@°ŗ+dĘČL…¼Ü.V¹æÄ؅¦÷²Iŗ¦÷½ˆ¾÷gĻÖa€G<‚€@œśÖČS³ĀŠ 0+¢ŗ>·1F¬”¬×ń|>?!X¬Ökj¹#¶o”Ȁ),±"Å;›b2X—%ŹįŅÄd*įæ3[dļd–-Öą;UL_üg«kėżehØ(H&„ß½kÖɀfĢŃšW޹+ĻČÕņ)Ē… ÉVjųexWP¬Ö$».ģZ¢„Ml¬¬«‹?—Q-×}Nø ص B!Ž…4 O)‹­hA¾ƒ†3põ°VhĶjk’®‡ŠxƒFnƆX֊üڧ«ä*)É]hĀ 1¢Šė ©B§„?ŒŹńĻ`La‡’”RCn‘Õ”ÖČcVR+ϐŀ‰Ź®€ÖŒE&l9 ōHŒ5©”0 y‚ fvf­”ˆG%t¹zł*W’œ‘Ź Ā v†Cµuud°27‰ŹĒ®ÕĆc 0ʏ6],­ėeš`čČ+ć9?g¢ +PIi†„÷µjŹĀ55­އ£%‹»XOh"5l=Ÿ@L …ą”CY@Šŗ ‚uŅ2·Ń 2Ä?ĖaŒL0Ę-°ČõŁS£Cœbž“GFJq $ģF†o²żvŅ„‹5­k@ĆZ0ūsc Ft%e}} Ŗ©^xe(+äAŒ!"²D=¤B­ųo%‹d0ƌ\±„f¦­ƒtNÉ&Łˆ˜©bāYćs<‡‡š„ĀI,[¢B(esEu®yÓ”R>ˆ°8 ΘDB„ĶĶ­‰ ¤ēēņ5•+ÉV„T§"-ĀńbµN±@‰Éń¶X+c;1 «\­ƒuß}tŠ ™S*#xėD!„kPĒ &µ§W› 1$ķb@A·°AAQ®YYJ²°`Aoa® X±GęņĢĒ&0u+¢œ7žŒż>BåWŒø²‹?Lśé¾–¼|\ ON$P"• ƒģ’IhW”Y-M4bCMrØa`~ŗ Ų±mGV`€(ČČŹU•°ĀÖcmTZŽ…gįóq“e)ĄNB…ÖæÖ“Ć*œ„Ī\%EĄ«Ķå‘ c°ø«ć°p«§dA(A6I&š †:Y[#! 9T C.–lśt"Å*ČĖMdjڶFB… šBXŌq‚JāOÉex§qSB5Ļ:לŠB ģ«,łÜo#0’š²¬M醒œB”_$ ŒAR‘@!® 4U•‘ŃŌ”FV,5KTJ* ¤Ø$Ä»ÅG˜ēž;ōØ­jZ–‘FE^ysŪǦ7™ĪXĄ¬*+Ē«ĒcąŌÓ`A(A i³6ś”lF[+jž_2Œ¤­<#K,D•Eō£FC«)FV[%’ȬŃVZVQ«eoĪy=ŌĪ÷¾ŗč©ZÖ(žN¶ń¹öxŗ|Ö\“ź 8‡Ē.*±Š‚"Å ģ–ōaŽO”Q`Ž@!ƒ šU•“• @VVTĄĄś!ƂX,€×Ej W¬üNV ¬f÷½–,\æjźÕš„`M÷ߓQwšńžÉęč4ŹH˜ēÄ6)ƒŠ‹vO@Ć,'Ћ֠ŒL%‰õ „YYJŖŖśb2øm‚]1hA– Յ‹R„UÖ"*•»Ä5‚»;ļ¢Y‹—/Ś25f¦¬‚ÅŸ»FzyĢ;•Č]!ĮUĒÄ[ˆĘ ,²v§m,;‚ 3cÓR`BØRXNƒ‡”®d“bKGWFCZJYB"ņ“ßsƒa¼^–m£’w¤ČóŁ?Õr’¾f’ōߌæż†W/®śīøzŅ”,B“'ˆ8NŹ&Ą#®¾‚Ås ĄÄ¼0z_A·F„rČÉĄӋIn“BŗŪ\T}lōX³±bä°(čQ%v-ŠÕ^Kž[<6`7%µX¬ć?ō9Ór’²e’bŹž—ę)¢Æ’ē2æ1åqCų›U|‚tÅ«¬üåŌ00‚µ #„äƲO„)ŃBZ¶ÆßAŌ©ŁöKB…ķ*B…(ōUĮ&l7]3$‚¢s®tEKņ„kŠ*¦ÜĶ^)ČBQæQś¼ļčž;ĄgųÜ_ €£ńž;ĒÆ–oŠež«'Ī]—ƒłÆÅä “.†jņ|fmųyŲ„B6-˜VŠL B¬Y¢4«jȄ"ĄC–Łbżu½0²±. 1B„Łw³µ‚Ļįš×BŌ”WZQWņÕ~VŒwĪĒÉP®×?ż=’O‘ēĶųŸÄžc…ü_ ų·žeć¼:Ł×]ŚX~Æ ĮcxLÆwź0J0!N9Į·Šė{č‘Ƶu)ójŹčA£Ÿ{ĖžH((JŖU¾+ÓlļbÄP¢„­é¬WXOŸĒē¤ ?1ć|‰~*šfPņH'˜šŽGł–ņĢO⸿DZŸćxż¬@‘ n¶} †Óm“õ8¹dŸćn”L!†±Ę š…ž‹+)HjŪG,4Cš› § †`tDUMs­0ė¦,ĢĢĮ»WŖŌ,ĄŌ@hÖ(Ē«3-Ę\”±‡‘H¦¦¬€Q®•P(X¬¶} …śy•VU9”āĻfähŖ&2`¦(;SVHŽĢ¬ •$’##F…H E‹°ŠÆęõ|JŹVƒJĖÜÄFf-ouJĄ;-S-b°n°ü~%g9›¬yæ’Ä@ !1 0A"@Qaq‘”2P±Į#3BRrŃCįšb‚ń$S’Ā’Ś? ‚ ”¬»Q„I²“®lØÉ8`Wj„p!;4éSžpŗ„vŁFEQGŠ(ģžöŹ0Œg7¼ŁQ€ą‚¶T”IĀź ‘ŻŪlƒ}ŠŚ”¾ŗą›®†c†ĒVÅö:÷š¤ąS»æD•WÜNčå}Ą Qēµ T(ßYYF½…o€ĘœdmĘÕåIAĄ.­‚ į!FÄįmō :Š-P†,§„Õq擟„?&¦;„*ĒŽx ęyZMä…”7*ah¤Ž˜+@Ŗa„Ń=gœÉķāŽū­#@¬iÖąEƇXӇxš@Ų”©ķ)xŒÄlŻODŗ¶āŻ,„@Føćlu4’Ćł‚–j©RŠz‘åę:®iąTį.“5.…ä ĘÄ®„oŃoŒlß é×ĀŲūģQ īÅPläaĶ#«¢YE}qł†E;5 ›MÜn>HķńD„OEƒ²~tlh”ąŚ¢YCć¬tPź!ÜZ~jT.Z­ƒĆm6÷œ#’®„J”hŽF§ģ_Jr2µ©‚”ĄõŠ*5ŌĻęiŽńtA « tANsį ŗAŒ›aį±|8¢\ÆtŃ@ :1ų$ØĀ ¶Į§¦šp#ųRŅÕ*ZCę­V‡ž“(2¹< Օī®Łč~gXam’†²€,µGGœOĄ®Š•yWŲ4źµćS4C†||n0±6ä]EÆź±ł}%IŗČÆm[›ī¶Ć lIĄ@P|r Ö ‚² Œ[SD€¼ ZPžž‰«VzŠ®kR$Ī©s{ā~ˆėĄAĖģō5¼ļAŒį*čYYIWųŻÕ•Õ±ŗ¶H÷­z@­S=]ZԽޑNÆl-M>£8ī›&“k›Ž‘Xæ‡ķž*źźčź‰\ߏH ź6)^ĻLhżVYµJ–އ¬Āµ#‹ł!SKmAłŚ<Ő¤Į£·>?ĘÜ+ą2VWųéjFĄ‹3KŚį“‡Ļ [c;ƒšŠ“ʬĘwøI\…Cśśßµ®w¬ź¹=ŸƒB£ūõZ>n>‹OuØhōŁū‹ŸņÕ ž {õ…FŌŲõś”Ź›¢iĶ Øė5ĀĶqź ū¤š¼kc-AŒuC•)?ē¢uG—»3}˜*Q•D;Ā6ē¦”äąŠ¼;łĆŠ•«–6…(N ”*hĢfłŪ$ vī”³³JäżńkÓo{„łL®F„•B’ŚŅ}L¢·šØ½Żä7嬓ē~&7¾]õŃrĶ_ėjō€ß¤ś­3HüZÆw{‰ś§Tpk“Ąf“·†‘LÌ8ņDåeP1Ļ{ŚDę]¬'VZZ<ėŲę©hķi­V5‰'=b, :¹I²e %ō™$4‘~1ińą‹N°0B}m•GūĪcIļ-śį­š¬wŗśf|ü':ļvØźł­fŅ{ÉžV†ų€Gq+XM'OažSéWˆ(毀 ²·ĄJ*ŲA…Ö‹Mś!Gc^ń z¢Ź€Ä*UŲ5Ą(šd·6Įšś-f‘>JŃ}©Ļq†·¬öö>K–k“÷ŀšh ĻŖå¾XaØĶz­\ėOf±ƒą«hµM*Ģ,x̤ÕsšÖĮiÉ ¹Čs¢ē€ĢŖōØ{Wv ĖA$ė@øČ™ģAģ$TƈL0Tiœƒq˜ą˜ä×hā„GC® ŸĢÖøš¼;›ŖP-=kA§ ih{O»$±Ąė¶Mł¦Ā’$Ę×§V“uuZZ@‹‹ž­oŹ`Č9*Ī Q›Zā@pzÄɈ‰&ĖHØlCDjĄ ™6ķ7’©TóÜ]Žg³č(é_£Ōsq NƤ6Æ(·ŁQi’ŅF»ū NØę‹Ü֏GcY¤uą„kĪ'ÄatCˆ*H© ”½«Eٟqž ;7 Ż B“ļäØĄ“±| ”8 ™Ļ „F.‚+)AF>ҧ“p³T‰+YŅP5kčwéqćź”ĮŻxPå Š­‘ČŒĮąGłqeŹ”_š*·iĢõļ\³W65Ÿ¹Ć’e\޶ŃūZO̵rs?õā=ś®G£•q.ł˜Z=©µ½Ķä9īłĖZ€xͧУš…Ŗžū(ołž|Ēr˜iŸĢōŁŗ¼+n ęŪ£ž1…Ō…]ZvA¾ƳudT‹ąeIA_hø†ŒŹmAńCŠ•ĆZ…VväŗšsÕŃEF:™üĆÕE޵e \Š`sĪMi>ŸĪĮ%^TØ ūr„n.(B“`²„Ä©Āņ” a;WE-„)P§aļ¾AP ,9ŻiÄĀ.Ķ@²²€¾šŽ¶øz¬Ņ:ś.Øż&ŸD)V1Åu/¼DĀöZ?±ó®{ćž3€ą„@Q¹…e;b7ץ ”N7Āp={«lĮR¬®ŸYŠŠ™LKīT źL”B¼(j)€ń?B„£üÉjŌ#¢H ¢ķ‹¶Ēåō@^šSXÓYł0Ońźœ÷8É;ĘŲ@•:Ģ•B’ ļ,§ŖčM¢ĄÖį! G4×fˆ„J "ķ%€q0³óóžź\1Š…o“h§śŒ‘ū‡8z·Õ`QĄ…*L Ž<ćō\AR²Qš³8Fąįņ6n§%dTØS„Ń ųB+ٰŸHąA²ŌtŚÖ§b¹ŚÆ±@„A„)=śC½ŚL'żÄj“ģ} – ąHś…=ŲéźõśüĀ?*U£1Ün= ¤Śb.⽦”ņ2…±¾ßŪ„t#µ•ļ.Œl‡×¾€VWM&B{ §XCóU)W\u yČÓŠiRmµÉq탪'ŗńŽ”īodłt[-jf3āéšv˜Ķ!ä‰`;:ü!SŠt79–&üØĘN ĮEŌŽŌŽŌŽ2š›(JjØ`S‘EQģGŠ(āpoŽŌŌŽŌŌŌŌ0(¢Šrrr(¢‚ ½©±ÅöŽīBPœ!ĮŻ[¦ėYs9Ł!©ez1—³ęgÖWżĖ{’ę…Ķ€‚ ‚m†Olwpō„u)Ē»?ųFlŗŠ”Łŗjµ’Ä# 0’Ś0‡x3 óē!Ņyó™3!’Š@Ļ-|µjא:Ļį™3Ī5Ļ>a232=#2dfLΘōRÓĪ'IY€“ ió;ŒHÅ!•„""XjÖÕOįķžį31z×§¦1˜‘„b'›˜TƜ*ü3‚33·ūĢ É˜Œ¢U鈖,5Ģ:_čž^ó®õ‰6d"cŚ0<įÖÓü²Š‡Gy‘ė2dĪ—­]ŲĘ0‡AXC­Œc‰ŪÖõ½ŸČ®³-Ö’/ń°uŒzTÄC¢l]ÕVh½ z:I„†6ž°u‰‚$!Äc³voó“mō[ōļū6ż«žÅæIśŽžĒĆ÷Ē£¶cońŒcĘg^½nģUŻ-½Fn°ķķŻQ]XMŖ:"ĶV1–ó·ē·čr~×’r’°żķæēą:c2r_‡š3:Ę==kÓ1͚»č·Æ^›śõź¬Ż! £Öī¶õ­µ[nśŃcŠQ‚>·"F=dęś9fß„nVŸóņ€ō’ü?å„ĢéUŒFŹė«¬_M·×¦Ś»ź¶B3D‹»Ŗ¾µ°źččļvwF­]Ŗ:B+э·——ŸčŠ*ĪWŠgOõłÜ³+ŅÅŻ݌F1Š­Ū7÷ė}Ż•G}ģŅŽ½zmė}®īģÕ«»¶ut±rĆ«¾½7Õ[žƒéoZ• +OM½·źĖüóŪŠĘ1ėzN·ł“°Å[]²ś-½nˆś­¶¶›ÖŖ¶l+ŗ[Óm„Ņ͘«é·ŖŽ¼æō’”vķ½+Ę~iJ™™0-N>ˆ¦Foy>š~O9XōōĻA³&F%„„›¬bčd!Šś…„µ¬Ū׶ͽūoķ·¦ž‹nśÕō+¬Ż-T¶–ōŗŪWwģ ffgm›Ā§KU«?“u_įń°ėY—–m-ŒĀG[עŅuUuu³oM„KoėÓa‹ŖōJŹĆ­Ż]õ»öVš3 Mʶ-“jÖ '!Ćy³zÕg™™™‘Œ³ih¬{Ķ!ŁŃ`Ŗ««±wY£č›»ŗ»½‰bŇ}ZĶ·[}gŠQyķ’M­jkzZR¶Ŗ#iĶ>ŁĆĶ»½ft‰Ė6––陑!ˆL&MUŒz]޶č„&bu˜LĆ”ŻÕ]Õä«n&š„©YY^š[ŽÜv„« [VžŪ]½ —3<ę"y¹˜˜‰e–_éčD: TČōŒeæƒł!ŃMķģWÓoM·×©ĖZŪ“¢VT¬Ø@ij_ŽõI¢[ŪiłW§Éžu’;ō~g/͉™™ŪˆŁlŁŽŽ“„z!Ń W“KKwŸĮ+ŠĶŻžw”z՛Ń>¾ĖōJŹéV󐹎ģ'Éóü—óqrü’gŻóŃÆY‰˜ōĖ6b[­ž4uDv¶Ŗ:°ķ,&v‡ņ1ž6zŃa„Lśųžč”””„&¶µķk¶ōOž’%žc>ϟōø¾Źš­s¬Ļ(¶½¬ŗ¬OāŃKzßZ#[zŽŽ“-TLW­:Y³zŻŻŻķéėw•ęä¢ĀQ¤,ŚÖµ›Y³ż“ü—×Å8ē×_Ń~Ź|Ā&fM³Ék-»cü+eŻ-čj„×§’Mžön˜™å0ėė᤹*ÖŽ{y-vķ•š’õpņp?„o°ēćą™‘–mkZü—äÕō«5[.īŽŽ‰jŪwwmfŽ·¦lƬčž6nÅŻŲO%JZžXū©Ė,Į­‹{nŻ»mž÷koÄśųķńņ~½łŸŠæĻUŃźŅöä·'-®ŗ¬YŖ¬z`JŚ®īź¶čUT;+™1ė^Č;¤ ™RµhŃ®gÕ_Ŗ°č}zõ»4ė&¶lņ<¼|ō·į}_ś¶ś9¾žf/ńyÉn[ف«¶ė^ņfœĢ!b3ucڐ„#3?†/Cٵ­|…B¶„Ø×.sŚ«’ƒü­Æn[s[žßCōqrēÆģņż<ü4–•š¬¼åœØf.õˆ3ϜĢ̇dŲ³zfĀdǶ"tM„%k@©ZքF©jĻ·ę”cśĢĻ>Ró’÷ę·-¹/är’żO§‡÷yØZ1‚±YĻ9„޶&*A®f3!C¤ĻįėFfT-Zł(‰€L”•+Zžk\é-ßčה¼Ģ̾|•(ŅÕå9ē"¹ćž‘nKóžæīżÜ›ZrNjf(@„CĪfŚykå«_>p3ϟ-s 2•*ˆĢĢč*ŅՇJŖŚ~ĒÉņž§šņKŸµ÷s~„ij•xĪ Wßż-ĢŁe­ė×åņ|ܟ7ŃNæąż?޼ۄıĶVmšĀ Vff`5ĢĢDĻ%Jłóå­«ēČT¬+ē2 ŪÖ&DĀ–Æ'²ķ­eŸ/ēü\æ'ļ…ł©łßCoö5Ÿ=¾»<&ńüTü:ž’’/ėēĻŽÆł/»ƒ›–æ½ņż¼_)4,^¼Ē)ŖZ€T!2gGX˜ĶeD Ģ DĻ$¼zÆLLĄĢb‰Š–ŻU_Īąų'ßö~µł%Ååߊų~ÆóÕüZ~įpžW -Évķē-yi~/ł'x8Ÿš’_©ÅūŸßĆcH;iĖn^š„;zŃ?”„Ģ:ČĢj „:#ŁÖ’X™7£­Żmóńńņ|öū¹’Bü§Ģ~o!š¼Õ²Z·9Ś÷µ›ĖÖÜņ8Ž2•?źłßžæ±Į÷p}tƒ¾Æ~YuėBémß[ «°„Ąlz²Bzf“_čéZu«»8~.?š¼g×÷}|Ļ4üłī³9kōW’z.r<–äµŪ+<łĢq?ę~ƚ’«oŅlī¶nņrņśŲL fžČ;7²Gų×ųŻŃ]ŻRÓŪ7G®›ƒćāąę99¹ymĶĶNIńĻ¢æ™Ė¼–ś-ʶ-ķ»vĖæŲ’üŪėxæsļż_ŗ”W’ö^ΈMÕōŪ֌õč·Æ[Ŗ#¾·uwufé7WK.śŃVŹ­›¶ō;ņšńqqW…śų¹ķōŽ÷·;_Ļ>ź~OŃo§“čęęäŗļ­Żž÷j’ūŽõ7,ŪÖ¶æ"ÆZA,;«°H ÕQmėÓo^Ė6ō[ÖīĀ3G}X[×¢ŽĖō,lŚÖßSŠü_-xnjńśæ›’O£ä_†’§N.K}ē·#ež·vj«n+rŪ’×ž—źąŖśmÉemŅĻDŅŪ«F=1w߯~½­Ėś-ėׯ[ŅĒ­lßKzõč±aÕVś؟™ÅĒĘVq¼ļ7ÕõSčāä¬ćųł‹ Ūońŗ­ŽOśõŖ«K5’7žÓō~›…ĀÖ/c”{::aŃFŒc-G£ų:¬aŃÓÓžŽŽˆ e”Ł×ąĪ8õĒ>‰ōĖO¦ruYYÉé閖Œ%e{cÕgēO–’rŹĖĒś’Ä9!1 0@AQ"`aq‘2BP”#R±ĮšŃ$p€’Ś?’ŹGž”¦3pTG9T†@•ŃŖ”Č*皪sqUy8­&…Ž1·Ć0©i ĒLČīÓ™ ˆę© ¤©ÉŖ±6€«iĪĢźm»B¬+³Ż'“?tڌmĮī«i‰qF{VvETp‚Q9”o…Fh²¤~ś÷P4r õK²åŗƒ3» Ņcóä[xęS nd„ā+~ū‚ćtP‚µ†ä±Ķ©Š…iįćēS«3ÕG×q"†ƒ!Īč Ö*duF¦ŽŅsĖÓŗ}§¾)Ōé±}>†}xK|’µ+ Ū¢Š;Ø(³KaäįöīE¶ĪŧT„‚(ž±ˆ|&}^ĄįĢp׹£ŽČ׈‹„ՕĪī7±5І›I& łHՆ” źü.)Ģ?•ˆĄUj u‚¤Ńy*“0ū .Ó#Pī`.ĔЭššE8Ī~É“ĘKPÆ“Č@ŲŲļ#ø=™č Ź‘ĀįØŠÆiMÆź* ź!Ė|· wZB—Gķ4P?)SØBlĄRT8ū©ŪŽįF² ü4Tu3Ģ(¹CTŗG5!KÜ|·BĆø*­ĀJö:C]⹩$”Ö¶T7ÄßqĢüÄ!ĄK<•åHį;AAB¶Š×ų( /hģ;€;"Ē-WąČŗŻV&:‘äVĀe$ęvå[UÕø™ł\TWįqQŽ–GGž£IŸ CĻ’ĆNQ½£¼t. ‡+p˜^[Õ9˜j768ź›[EkŪ“šŁ9¬ Õū,ņ\õHąšTM4ŵ‡ąG—œ¹o€ī“„cĖV)5Ęåƒ2>©„Ę&"ź?šįˆ·Vī=W UI ĮPao‡ØŲ¾Ŗm÷œÕhķē>A0{­%T>ė@ū­!ßy'»Žq?]N<‘‰Xs* ĀŽ…<éœĢń€ĪēģœfŠ*¬™ż?Ņh0ᢓ‹ģŁ[UųƒĮƒĀŪ`fäĀčæ²H^ŚOÄŖöokśPŌŻž#rr »ĶŻJ­[ÜżVň­Ÿņ%0¶IM.njąŽHņ²(Q®*ĒTXšįČŹmZmØ> r±D¦čä6ļµ~0ģ™įń<j>Jk/ąšI„Xłå‘Xč4ųG¦§b¦y_×S\ŠŅ`„Ņ@€¼:«?Ża?E¤œŪe?āpžz*c2J¦Ü‚Œ¶K\ÓQ©”į9“Ā*ZÉFKį;Ühs?¢m6IR ¹*źYä¬ęt殦iĖ—čz­"™°Äį^Ȑ„³)r-t«ĒÉ-Ēāt“¦E•J™ØRgTµĆź°Ö6į!ZC]Š©øRöd'bF@“µyīÉ5¼ÖŠ.Ķ]I_‰AdZąī…MĒ użĘŠĒó……LŹc©ˆd6‡É/Ę:¦Y ܆¢Ū#FuŠ“«Ø¬Óā”Ä,TįoN®kč“pd(”¤J>ćs* ‹©²·É‡j:tfč‚sJ«ĀÉ`ńPļ+z,8›õCTš_Ū’V¢ć“å§ė—Ž M§2Q}BćŹĆ÷Łč§?—Fčj¾øRŠĄĄ5ÉM-‚’3F™ĆPHź­‰¹+®j+µŻ/üś®Ń=`¬/žéĶn6ęŅś#_Bf‘NįĶŌ*®¬qX Ģõ]—ŽQą­ØIPŌa2¢}#ŁÉ1āFk’ ŠŪP —L‚ĒE§„øX*AUz_Ņ£ü9Ī3 śMnŃń:įŲ:ķ¼:ŽĮÖQEgh¢Ž³ź+ń ōŌee:Ž.ĪiŃuڶkµtĻń­ņūĻū_€ļ1ź»GrQE99v߆¤e*īœÓ9Źg$‘Ų’’Łspotipy-dev-spotipy-8a40e03/docs/index.rst000066400000000000000000000354541462616510500206420ustar00rootroot00000000000000.. image:: images/spotify-web-api-doc.jpg :width: 100 % Welcome to Spotipy! =================================== *Spotipy* is a lightweight Python library for the `Spotify Web API `_. With *Spotipy* you get full access to all of the music data provided by the Spotify platform. Features ======== *Spotipy* supports all of the features of the Spotify Web API including access to all end points, and support for user authorization. For details on the capabilities you are encouraged to review the `Spotify Web API `_ documentation. Installation ============ Install or upgrade *Spotipy* with:: pip install spotipy --upgrade You can also obtain the source code from the `Spotify GitHub repository `_. Getting Started =============== All methods require user authorization. You will need to register your app at `My Dashboard `_ to get the credentials necessary to make authorized calls (a *client id* and *client secret*). *Spotipy* supports two authorization flows: - **Authorization Code flow** This method is suitable for long-running applications which the user logs into once. It provides an access token that can be refreshed. .. note:: Requires you to add a redirect URI to your application at `My Dashboard `_. See `Redirect URI`_ for more details. - **Client Credentials flow** This method makes it possible to authenticate your requests to the Spotify Web API and to obtain a higher rate limit than you would with the Authorization Code flow. For guidance on setting your app credentials watch this `video tutorial `_ or follow the `Spotipy Tutorial for Beginners `_. For a longer tutorial with examples included, refer to this `video playlist `_. Authorization Code Flow ======================= This flow is suitable for long-running applications in which the user grants permission only once. It provides an access token that can be refreshed. Since the token exchange involves sending your secret key, perform this on a secure location, like a backend service, and not from a client such as a browser or from a mobile app. Quick start ----------- To support the **Client Authorization Code Flow** *Spotipy* provides a class SpotifyOAuth that can be used to authenticate requests like so:: import spotipy from spotipy.oauth2 import SpotifyOAuth scope = "user-library-read" sp = spotipy.Spotify(auth_manager=SpotifyOAuth(scope=scope)) results = sp.current_user_saved_tracks() for idx, item in enumerate(results['items']): track = item['track'] print(idx, track['artists'][0]['name'], " – ", track['name']) or if you are reluctant to immortalize your app credentials in your source code, you can set environment variables like so (use ``$env:"credentials"`` instead of ``export`` on Windows):: export SPOTIPY_CLIENT_ID='your-spotify-client-id' export SPOTIPY_CLIENT_SECRET='your-spotify-client-secret' export SPOTIPY_REDIRECT_URI='your-app-redirect-url' Scopes ------ See `Using Scopes `_ for information about scopes. Redirect URI ------------ The **Authorization Code Flow** needs you to add a **redirect URI** to your application at `My Dashboard `_ (navigate to your application and then *[Edit Settings]*). The ``redirect_uri`` argument or ``SPOTIPY_REDIRECT_URI`` environment variable must match the redirect URI added to your application in your Dashboard. The redirect URI can be any valid URI (it does not need to be accessible) such as ``http://example.com``, ``http://localhost`` or ``http://127.0.0.1:9090``. .. note:: If you choose an `http`-scheme URL, and it's for `localhost` or `127.0.0.1`, **AND** it specifies a port, then spotipy will instantiate a server on the indicated response to receive the access token from the response at the end of the oauth flow [see the code](https://github.com/plamere/spotipy/blob/master/spotipy/oauth2.py#L483-L490). Client Credentials Flow ======================= The Client Credentials flow is used in server-to-server authentication. Only endpoints that do not access user information can be accessed. The advantage here in comparison with requests to the Web API made without an access token, is that a higher rate limit is applied. As opposed to the Authorization Code Flow, you will not need to set ``SPOTIPY_REDIRECT_URI``, which means you will never be redirected to the sign in page in your browser:: export SPOTIPY_CLIENT_ID='your-spotify-client-id' export SPOTIPY_CLIENT_SECRET='your-spotify-client-secret' To support the **Client Credentials Flow** *Spotipy* provides a class SpotifyClientCredentials that can be used to authenticate requests like so:: import spotipy from spotipy.oauth2 import SpotifyClientCredentials auth_manager = SpotifyClientCredentials() sp = spotipy.Spotify(auth_manager=auth_manager) playlists = sp.user_playlists('spotify') while playlists: for i, playlist in enumerate(playlists['items']): print("%4d %s %s" % (i + 1 + playlists['offset'], playlist['uri'], playlist['name'])) if playlists['next']: playlists = sp.next(playlists) else: playlists = None IDs URIs and URLs ================= *Spotipy* supports a number of different ID types: - **Spotify URI** - The resource identifier that you can enter, for example, in the Spotify Desktop client's search box to locate an artist, album, or track. Example: ``spotify:track:6rqhFgbbKwnb9MLmUQDhG6`` - **Spotify URL** - An HTML link that opens a track, album, app, playlist or other Spotify resource in a Spotify client. Example: ``http://open.spotify.com/track/6rqhFgbbKwnb9MLmUQDhG6`` - **Spotify ID** - A base-62 number that you can find at the end of the Spotify URI (see above) for an artist, track, album, etc. Example: ``6rqhFgbbKwnb9MLmUQDhG6`` In general, any *Spotipy* method that needs an artist, album, track or playlist ID will accept ids in any of the above form Customized token caching ======================== Tokens are refreshed automatically and stored by default in the project main folder. As this might not suit everyone's needs, spotipy provides a way to create customized cache handlers. https://github.com/plamere/spotipy/blob/master/spotipy/cache_handler.py The custom cache handler would need to be a class that inherits from the base cache handler ``CacheHandler``. The default cache handler ``CacheFileHandler`` is a good example. An instance of that new class can then be passed as a parameter when creating ``SpotifyOAuth``, ``SpotifyPKCE`` or ``SpotifyImplicitGrant``. The following handlers are available and defined in the URL above. - ``CacheFileHandler`` - ``MemoryCacheHandler`` - ``DjangoSessionCacheHandler`` - ``FlaskSessionCacheHandler`` - ``RedisCacheHandler`` - ``MemcacheCacheHandler``: install with dependency using ``pip install "spotipy[pymemcache]"`` Feel free to contribute new cache handlers to the repo. Examples ======================= Here is an example of using *Spotipy* to list the names of all the albums released by the artist 'Birdy':: import spotipy from spotipy.oauth2 import SpotifyClientCredentials birdy_uri = 'spotify:artist:2WX2uTcsvV5OnS0inACecP' spotify = spotipy.Spotify(client_credentials_manager=SpotifyClientCredentials()) results = spotify.artist_albums(birdy_uri, album_type='album') albums = results['items'] while results['next']: results = spotify.next(results) albums.extend(results['items']) for album in albums: print(album['name']) Here's another example showing how to get 30 second samples and cover art for the top 10 tracks for Led Zeppelin:: import spotipy from spotipy.oauth2 import SpotifyClientCredentials lz_uri = 'spotify:artist:36QJpDe2go2KgaRleHCDTp' spotify = spotipy.Spotify(client_credentials_manager=SpotifyClientCredentials()) results = spotify.artist_top_tracks(lz_uri) for track in results['tracks'][:10]: print('track : ' + track['name']) print('audio : ' + track['preview_url']) print('cover art: ' + track['album']['images'][0]['url']) print() Finally, here's an example that will get the URL for an artist image given the artist's name:: import spotipy import sys from spotipy.oauth2 import SpotifyClientCredentials spotify = spotipy.Spotify(auth_manager=SpotifyClientCredentials()) if len(sys.argv) > 1: name = ' '.join(sys.argv[1:]) else: name = 'Radiohead' results = spotify.search(q='artist:' + name, type='artist') items = results['artists']['items'] if len(items) > 0: artist = items[0] print(artist['name'], artist['images'][0]['url']) There are many more examples of how to use *Spotipy* in the `Examples Directory `_ on GitHub. API Reference ============== :mod:`client` Module ======================= .. automodule:: spotipy.client :members: :undoc-members: :special-members: __init__ :show-inheritance: :mod:`oauth2` Module ======================= .. automodule:: spotipy.oauth2 :members: :undoc-members: :special-members: __init__ :show-inheritance: :mod:`util` Module -------------------- .. automodule:: spotipy.util :members: :undoc-members: :special-members: __init__ :show-inheritance: Support ======= You can ask questions about Spotipy on Stack Overflow. Don’t forget to add the *Spotipy* tag, and any other relevant tags as well, before posting. http://stackoverflow.com/questions/ask If you think you've found a bug, let us know at `Spotipy Issues `_ Contribute ========== Spotipy authored by Paul Lamere (plamere) with contributions by: - Daniel Beaudry (`danbeaudry on Github `_) - Faruk Emre Sahin (`fsahin on Github `_) - George (`rogueleaderr on Github `_) - Henry Greville (`sethaurus on Github `_) - Hugo van Kemanade (`hugovk on Github `_) - JosĆ© Manuel PĆ©rez (`JMPerez on Github `_) - Lucas Nunno (`lnunno on Github `_) - Lynn Root (`econchick on Github `_) - Matt Dennewitz (`mattdennewitz on Github `_) - Matthew Duck (`mattduck on Github `_) - Michael Thelin (`thelinmichael on Github `_) - Ryan Choi (`ryankicks on Github `_) - Simon Metson (`drsm79 on Github `_) - Steve Winton (`swinton on Github `_) - Tim Balzer (`timbalzer on Github `_) - `corycorycory on Github `_ - Nathan Coleman (`nathancoleman on Github `_) - Michael Birtwell (`mbirtwell on Github `_) - Harrison Hayes (`Harrison97 on Github `_) - Stephane Bruckert (`stephanebruckert on Github `_) - Ritiek Malhotra (`ritiek on Github `_) If you are a developer with Python experience, and you would like to contribute to Spotipy, please be sure to follow the guidelines listed below: Export the needed Environment variables::: export SPOTIPY_CLIENT_ID=client_id_here export SPOTIPY_CLIENT_SECRET=client_secret_here export SPOTIPY_CLIENT_USERNAME=client_username_here # This is actually an id not spotify display name export SPOTIPY_REDIRECT_URI=http://localhost:8080 # Make url is set in app you created to get your ID and SECRET Create virtual environment, install dependencies, run tests::: $ virtualenv --python=python3.12 env (env) $ pip install --user -e . (env) $ python -m unittest discover -v tests **Lint** To automatically fix the code style::: pip install autopep8 autopep8 --in-place --aggressive --recursive . To verify the code style::: pip install flake8 flake8 . To make sure if the import lists are stored correctly::: pip install isort isort . -c -v **Publishing (by maintainer)** - Bump version in setup.py - Bump and date changelog - Add to changelog: :: ## Unreleased // Add your changes here and then delete this line - Commit changes - Package to pypi: :: python setup.py sdist bdist_wheel python3 setup.py sdist bdist_wheel twine check dist/* twine upload --repository-url https://upload.pypi.org/legacy/ --skip-existing dist/*.(whl|gz|zip)~dist/*linux*.whl - 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 - Build the documentation again to ensure it's on the latest version **Changelog** Don't forget to add a short description of your change in the `CHANGELOG `_! License ======= (Taken from https://github.com/plamere/spotipy/blob/master/LICENSE.md):: MIT License Copyright (c) 2021 Paul Lamere Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. Indices and tables ================== * :ref:`genindex` * :ref:`modindex` * :ref:`search` spotipy-dev-spotipy-8a40e03/docs/make.bat000066400000000000000000000117521462616510500204010ustar00rootroot00000000000000@ECHO OFF REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set BUILDDIR=_build set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . set I18NSPHINXOPTS=%SPHINXOPTS% . if NOT "%PAPER%" == "" ( set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% ) if "%1" == "" goto help if "%1" == "help" ( :help echo.Please use `make ^` where ^ is one of echo. html to make standalone HTML files echo. dirhtml to make HTML files named index.html in directories echo. singlehtml to make a single large HTML file echo. pickle to make pickle files echo. json to make JSON files echo. htmlhelp to make HTML files and a HTML help project echo. qthelp to make HTML files and a qthelp project echo. devhelp to make HTML files and a Devhelp project echo. epub to make an epub echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter echo. text to make text files echo. man to make manual pages echo. texinfo to make Texinfo files echo. gettext to make PO message catalogs echo. changes to make an overview over all changed/added/deprecated items echo. linkcheck to check all external links for integrity echo. doctest to run all doctests embedded in the documentation if enabled goto end ) if "%1" == "clean" ( for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i del /q /s %BUILDDIR%\* goto end ) if "%1" == "html" ( %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/html. goto end ) if "%1" == "dirhtml" ( %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. goto end ) if "%1" == "singlehtml" ( %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. goto end ) if "%1" == "pickle" ( %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can process the pickle files. goto end ) if "%1" == "json" ( %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can process the JSON files. goto end ) if "%1" == "htmlhelp" ( %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can run HTML Help Workshop with the ^ .hhp project file in %BUILDDIR%/htmlhelp. goto end ) if "%1" == "qthelp" ( %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can run "qcollectiongenerator" with the ^ .qhcp project file in %BUILDDIR%/qthelp, like this: echo.^> qcollectiongenerator %BUILDDIR%\qthelp\spotipy.qhcp echo.To view the help file: echo.^> assistant -collectionFile %BUILDDIR%\qthelp\spotipy.ghc goto end ) if "%1" == "devhelp" ( %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp if errorlevel 1 exit /b 1 echo. echo.Build finished. goto end ) if "%1" == "epub" ( %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub if errorlevel 1 exit /b 1 echo. echo.Build finished. The epub file is in %BUILDDIR%/epub. goto end ) if "%1" == "latex" ( %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex if errorlevel 1 exit /b 1 echo. echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. goto end ) if "%1" == "text" ( %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text if errorlevel 1 exit /b 1 echo. echo.Build finished. The text files are in %BUILDDIR%/text. goto end ) if "%1" == "man" ( %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man if errorlevel 1 exit /b 1 echo. echo.Build finished. The manual pages are in %BUILDDIR%/man. goto end ) if "%1" == "texinfo" ( %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo if errorlevel 1 exit /b 1 echo. echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. goto end ) if "%1" == "gettext" ( %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale if errorlevel 1 exit /b 1 echo. echo.Build finished. The message catalogs are in %BUILDDIR%/locale. goto end ) if "%1" == "changes" ( %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes if errorlevel 1 exit /b 1 echo. echo.The overview file is in %BUILDDIR%/changes. goto end ) if "%1" == "linkcheck" ( %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck if errorlevel 1 exit /b 1 echo. echo.Link check complete; look for any errors in the above output ^ or in %BUILDDIR%/linkcheck/output.txt. goto end ) if "%1" == "doctest" ( %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest if errorlevel 1 exit /b 1 echo. echo.Testing of doctests in the sources finished, look at the ^ results in %BUILDDIR%/doctest/output.txt. goto end ) :end spotipy-dev-spotipy-8a40e03/docs/requirements.txt000066400000000000000000000000631462616510500222510ustar00rootroot00000000000000Sphinx~=7.3.7 sphinx-rtd-theme~=2.0.0 redis>=3.5.3 spotipy-dev-spotipy-8a40e03/examples/000077500000000000000000000000001462616510500176545ustar00rootroot00000000000000spotipy-dev-spotipy-8a40e03/examples/add_a_saved_album.py000066400000000000000000000012121462616510500236140ustar00rootroot00000000000000import argparse import logging import spotipy from spotipy.oauth2 import SpotifyOAuth logger = logging.getLogger('examples.add_a_saved_album') logging.basicConfig(level='DEBUG') scope = 'user-library-modify' def get_args(): parser = argparse.ArgumentParser(description='Creates a playlist for user') parser.add_argument('-a', '--aids', action='append', required=True, help='Album ids') return parser.parse_args() def main(): args = get_args() sp = spotipy.Spotify(auth_manager=SpotifyOAuth(scope=scope)) sp.current_user_saved_albums_add(albums=args.aids) if __name__ == '__main__': main() spotipy-dev-spotipy-8a40e03/examples/add_a_saved_track.py000066400000000000000000000013051462616510500236230ustar00rootroot00000000000000import argparse import logging import spotipy from spotipy.oauth2 import SpotifyOAuth scope = 'user-library-modify' logger = logging.getLogger('examples.add_a_saved_track') logging.basicConfig(level='DEBUG') def get_args(): parser = argparse.ArgumentParser(description='Add tracks to Your ' 'Collection of saved tracks') parser.add_argument('-t', '--tids', action='append', required=True, help='Track ids') return parser.parse_args() def main(): args = get_args() sp = spotipy.Spotify(auth_manager=SpotifyOAuth(scope=scope)) sp.current_user_saved_tracks_add(tracks=args.tids) if __name__ == '__main__': main() spotipy-dev-spotipy-8a40e03/examples/add_tracks_to_playlist.py000066400000000000000000000014031462616510500247460ustar00rootroot00000000000000import argparse import logging import spotipy from spotipy.oauth2 import SpotifyOAuth logger = logging.getLogger('examples.add_tracks_to_playlist') logging.basicConfig(level='DEBUG') scope = 'playlist-modify-public' def get_args(): parser = argparse.ArgumentParser(description='Adds track to user playlist') parser.add_argument('-u', '--uris', action='append', required=True, help='Track ids') parser.add_argument('-p', '--playlist', required=True, help='Playlist to add track to') return parser.parse_args() def main(): args = get_args() sp = spotipy.Spotify(auth_manager=SpotifyOAuth(scope=scope)) sp.playlist_add_items(args.playlist, args.uris) if __name__ == '__main__': main() spotipy-dev-spotipy-8a40e03/examples/app.py000066400000000000000000000102021462616510500210010ustar00rootroot00000000000000""" Prerequisites pip3 install spotipy Flask Flask-Session // from your [app settings](https://developer.spotify.com/dashboard/applications) export SPOTIPY_CLIENT_ID=client_id_here export SPOTIPY_CLIENT_SECRET=client_secret_here export SPOTIPY_REDIRECT_URI='http://127.0.0.1:8080' // must contain a port // SPOTIPY_REDIRECT_URI must be added to your [app settings](https://developer.spotify.com/dashboard/applications) OPTIONAL // in development environment for debug output export FLASK_ENV=development // so that you can invoke the app outside the file's directory include export FLASK_APP=/path/to/spotipy/examples/app.py // on Windows, use `SET` instead of `export` Run app.py python3 app.py OR python3 -m flask run NOTE: If receiving "port already in use" error, try other ports: 5000, 8090, 8888, etc... (will need to be updated in your Spotify app and SPOTIPY_REDIRECT_URI variable) """ import os from flask import Flask, session, request, redirect from flask_session import Session import spotipy app = Flask(__name__) app.config['SECRET_KEY'] = os.urandom(64) app.config['SESSION_TYPE'] = 'filesystem' app.config['SESSION_FILE_DIR'] = './.flask_session/' Session(app) @app.route('/') def index(): cache_handler = spotipy.cache_handler.FlaskSessionCacheHandler(session) auth_manager = spotipy.oauth2.SpotifyOAuth(scope='user-read-currently-playing playlist-modify-private', cache_handler=cache_handler, show_dialog=True) if request.args.get("code"): # Step 2. Being redirected from Spotify auth page auth_manager.get_access_token(request.args.get("code")) return redirect('/') if not auth_manager.validate_token(cache_handler.get_cached_token()): # Step 1. Display sign in link when no token auth_url = auth_manager.get_authorize_url() return f'

Sign in

' # Step 3. Signed in, display data spotify = spotipy.Spotify(auth_manager=auth_manager) return f'

Hi {spotify.me()["display_name"]}, ' \ f'[sign out]

' \ f'my playlists | ' \ f'currently playing | ' \ f'me' \ @app.route('/sign_out') def sign_out(): session.pop("token_info", None) return redirect('/') @app.route('/playlists') def playlists(): cache_handler = spotipy.cache_handler.FlaskSessionCacheHandler(session) auth_manager = spotipy.oauth2.SpotifyOAuth(cache_handler=cache_handler) if not auth_manager.validate_token(cache_handler.get_cached_token()): return redirect('/') spotify = spotipy.Spotify(auth_manager=auth_manager) return spotify.current_user_playlists() @app.route('/currently_playing') def currently_playing(): cache_handler = spotipy.cache_handler.FlaskSessionCacheHandler(session) auth_manager = spotipy.oauth2.SpotifyOAuth(cache_handler=cache_handler) if not auth_manager.validate_token(cache_handler.get_cached_token()): return redirect('/') spotify = spotipy.Spotify(auth_manager=auth_manager) track = spotify.current_user_playing_track() if not track is None: return track return "No track currently playing." @app.route('/current_user') def current_user(): cache_handler = spotipy.cache_handler.FlaskSessionCacheHandler(session) auth_manager = spotipy.oauth2.SpotifyOAuth(cache_handler=cache_handler) if not auth_manager.validate_token(cache_handler.get_cached_token()): return redirect('/') spotify = spotipy.Spotify(auth_manager=auth_manager) return spotify.current_user() ''' Following lines allow application to be run more conveniently with `python app.py` (Make sure you're using python3) (Also includes directive to leverage pythons threading capacity.) ''' if __name__ == '__main__': app.run(threaded=True, port=int(os.environ.get("PORT", os.environ.get("SPOTIPY_REDIRECT_URI", 8080).split(":")[-1]))) spotipy-dev-spotipy-8a40e03/examples/artist_albums.py000066400000000000000000000026111462616510500230770ustar00rootroot00000000000000import argparse import logging from spotipy.oauth2 import SpotifyClientCredentials import spotipy logger = logging.getLogger('examples.artist_albums') logging.basicConfig(level='INFO') sp = spotipy.Spotify(client_credentials_manager=SpotifyClientCredentials()) def get_args(): parser = argparse.ArgumentParser(description='Gets albums from artist') parser.add_argument('-a', '--artist', required=True, help='Name of Artist') return parser.parse_args() def get_artist(name): results = sp.search(q='artist:' + name, type='artist') items = results['artists']['items'] if len(items) > 0: return items[0] else: return None def show_artist_albums(artist): albums = [] results = sp.artist_albums(artist['id'], album_type='album') albums.extend(results['items']) while results['next']: results = sp.next(results) albums.extend(results['items']) seen = set() # to avoid dups albums.sort(key=lambda album: album['name'].lower()) for album in albums: name = album['name'] if name not in seen: logger.info('ALBUM: %s', name) seen.add(name) def main(): args = get_args() artist = get_artist(args.artist) if artist: show_artist_albums(artist) else: logger.error("Can't find artist: %s", artist) if __name__ == '__main__': main() spotipy-dev-spotipy-8a40e03/examples/artist_discography.py000066400000000000000000000041341462616510500241320ustar00rootroot00000000000000# Shows the list of all songs sung by the artist or the band import argparse import logging from spotipy.oauth2 import SpotifyClientCredentials import spotipy logger = logging.getLogger('examples.artist_discography') logging.basicConfig(level='INFO') def get_args(): parser = argparse.ArgumentParser(description='Shows albums and tracks for ' 'given artist') parser.add_argument('-a', '--artist', required=True, help='Name of Artist') return parser.parse_args() def get_artist(name): results = sp.search(q='artist:' + name, type='artist') items = results['artists']['items'] if len(items) > 0: return items[0] else: return None def show_album_tracks(album): tracks = [] results = sp.album_tracks(album['id']) tracks.extend(results['items']) while results['next']: results = sp.next(results) tracks.extend(results['items']) for i, track in enumerate(tracks): logger.info('%s. %s', i + 1, track['name']) def show_artist_albums(artist): albums = [] results = sp.artist_albums(artist['id'], album_type='album') albums.extend(results['items']) while results['next']: results = sp.next(results) albums.extend(results['items']) logger.info('Total albums: %s', len(albums)) unique = set() # skip duplicate albums for album in albums: name = album['name'].lower() if name not in unique: logger.info('ALBUM: %s', name) unique.add(name) show_album_tracks(album) def show_artist(artist): logger.info('====%s====', artist['name']) logger.info('Popularity: %s', artist['popularity']) if len(artist['genres']) > 0: logger.info('Genres: %s', ','.join(artist['genres'])) def main(): args = get_args() artist = get_artist(args.artist) show_artist(artist) show_artist_albums(artist) if __name__ == '__main__': client_credentials_manager = SpotifyClientCredentials() sp = spotipy.Spotify(client_credentials_manager=client_credentials_manager) main() spotipy-dev-spotipy-8a40e03/examples/artist_recommendations.py000066400000000000000000000024121462616510500250020ustar00rootroot00000000000000import argparse import logging import spotipy from spotipy.oauth2 import SpotifyClientCredentials logger = logging.getLogger('examples.artist_recommendations') logging.basicConfig(level='INFO') client_credentials_manager = SpotifyClientCredentials() sp = spotipy.Spotify(client_credentials_manager=client_credentials_manager) def get_args(): parser = argparse.ArgumentParser(description='Recommendations for the ' 'given artist') parser.add_argument('-a', '--artist', required=True, help='Name of Artist') return parser.parse_args() def get_artist(name): results = sp.search(q='artist:' + name, type='artist') items = results['artists']['items'] if len(items) > 0: return items[0] else: return None def show_recommendations_for_artist(artist): results = sp.recommendations(seed_artists=[artist['id']]) for track in results['tracks']: logger.info('Recommendation: %s - %s', track['name'], track['artists'][0]['name']) def main(): args = get_args() artist = get_artist(args.artist) if artist: show_recommendations_for_artist(artist) else: logger.error("Can't find that artist", args.artist) if __name__ == '__main__': main() spotipy-dev-spotipy-8a40e03/examples/audio_analysis_for_track.py000066400000000000000000000010511462616510500252610ustar00rootroot00000000000000# shows audio analysis for the given track from spotipy.oauth2 import SpotifyClientCredentials import json import spotipy import time import sys client_credentials_manager = SpotifyClientCredentials() sp = spotipy.Spotify(client_credentials_manager=client_credentials_manager) if len(sys.argv) > 1: tid = sys.argv[1] else: tid = 'spotify:track:4TTV7EcfroSLWzXRY6gLv6' start = time.time() analysis = sp.audio_analysis(tid) delta = time.time() - start print(json.dumps(analysis, indent=4)) print(f"analysis retrieved in {delta:.2f} seconds") spotipy-dev-spotipy-8a40e03/examples/audio_features.py000066400000000000000000000015671462616510500232360ustar00rootroot00000000000000# shows acoustic features for tracks for the given artist from spotipy.oauth2 import SpotifyClientCredentials import json import spotipy import time import sys client_credentials_manager = SpotifyClientCredentials() sp = spotipy.Spotify(client_credentials_manager=client_credentials_manager) sp.trace = False if len(sys.argv) > 1: artist_name = ' '.join(sys.argv[1:]) else: artist_name = 'weezer' results = sp.search(q=artist_name, limit=50) tids = [] for i, t in enumerate(results['tracks']['items']): print(' ', i, t['name']) tids.append(t['uri']) start = time.time() features = sp.audio_features(tids) delta = time.time() - start for feature in features: print(json.dumps(feature, indent=4)) print() analysis = sp._get(feature['analysis_url']) print(json.dumps(analysis, indent=4)) print() print(f"features retrieved in {delta:.2f} seconds") spotipy-dev-spotipy-8a40e03/examples/audio_features_for_track.py000066400000000000000000000010701462616510500252550ustar00rootroot00000000000000# shows acoustic features for tracks for the given artist from spotipy.oauth2 import SpotifyClientCredentials import json import spotipy import time import sys client_credentials_manager = SpotifyClientCredentials() sp = spotipy.Spotify(client_credentials_manager=client_credentials_manager) sp.trace = True if len(sys.argv) > 1: tids = sys.argv[1:] print(tids) start = time.time() features = sp.audio_features(tids) delta = time.time() - start print(json.dumps(features, indent=4)) print(f"features retrieved in {delta:.2f} seconds") spotipy-dev-spotipy-8a40e03/examples/change_playlist_details.py000066400000000000000000000034411462616510500251030ustar00rootroot00000000000000import argparse import logging import spotipy from spotipy.oauth2 import SpotifyOAuth logger = logging.getLogger('examples.change_playlist_details') logging.basicConfig(level='DEBUG') scope = 'playlist-modify-public playlist-modify-private' def get_args(): parser = argparse.ArgumentParser(description='Modify details of playlist') parser.add_argument('-p', '--playlist', required=True, help='Playlist id to alter details') parser.add_argument('-n', '--name', required=False, help='Name of playlist') parser.add_argument('--public', action='store_true', required=False, help='Include param if playlist is public') parser.add_argument('--private', action='store_false', required=False, default=None, help='Include param to make playlist is private') parser.add_argument('-c', '--collaborative', action='store_true', required=False, default=None, help='Include param if playlist is collaborative') parser.add_argument('-i', '--independent', action='store_false', required=False, default=None, help='Include param to make playlist non collaborative') parser.add_argument('-d', '--description', default=None, required=False, help='Description of playlist') return parser.parse_args() def main(): args = get_args() sp = spotipy.Spotify(auth_manager=SpotifyOAuth(scope=scope)) sp.playlist_change_details( args.playlist, name=args.name, public=args.public or args.private, collaborative=args.collaborative or args.independent, description=args.description) if __name__ == '__main__': main() spotipy-dev-spotipy-8a40e03/examples/client_credentials_flow.py000066400000000000000000000004451462616510500251130ustar00rootroot00000000000000from spotipy.oauth2 import SpotifyClientCredentials import spotipy from pprint import pprint client_credentials_manager = SpotifyClientCredentials() sp = spotipy.Spotify(client_credentials_manager=client_credentials_manager) search_str = 'Muse' result = sp.search(search_str) pprint(result) spotipy-dev-spotipy-8a40e03/examples/contains_a_saved_track.py000066400000000000000000000006671462616510500247230ustar00rootroot00000000000000# Prints whether a track exists in your collection of saved tracks import pprint import sys import spotipy from spotipy.oauth2 import SpotifyOAuth scope = 'user-library-read' if len(sys.argv) > 1: tid = sys.argv[1] else: print(f"Usage: {sys.argv[0]} track-id ...") sys.exit() sp = spotipy.Spotify(auth_manager=SpotifyOAuth(scope=scope)) results = sp.current_user_saved_tracks_contains(tracks=[tid]) pprint.pprint(results) spotipy-dev-spotipy-8a40e03/examples/create_playlist.py000066400000000000000000000015431462616510500234150ustar00rootroot00000000000000# Creates a playlist for a user import argparse import logging import spotipy from spotipy.oauth2 import SpotifyOAuth logger = logging.getLogger('examples.create_playlist') logging.basicConfig(level='DEBUG') def get_args(): parser = argparse.ArgumentParser(description='Creates a playlist for user') parser.add_argument('-p', '--playlist', required=True, help='Name of Playlist') parser.add_argument('-d', '--description', required=False, default='', help='Description of Playlist') return parser.parse_args() def main(): args = get_args() scope = "playlist-modify-public" sp = spotipy.Spotify(auth_manager=SpotifyOAuth(scope=scope)) user_id = sp.me()['id'] sp.user_playlist_create(user_id, args.playlist, description=args.description) if __name__ == '__main__': main() spotipy-dev-spotipy-8a40e03/examples/delete_a_saved_track.py000066400000000000000000000006541462616510500243430ustar00rootroot00000000000000# Delete a track from 'Your Collection' of saved tracks import pprint import sys import spotipy from spotipy.oauth2 import SpotifyOAuth scope = 'user-library-modify' if len(sys.argv) > 1: tid = sys.argv[1] else: print(f"Usage: {sys.argv[0]} track-id ...") sys.exit() sp = spotipy.Spotify(auth_manager=SpotifyOAuth(scope=scope)) results = sp.current_user_saved_tracks_delete(tracks=[tid]) pprint.pprint(results) spotipy-dev-spotipy-8a40e03/examples/follow_playlist.py000066400000000000000000000012471462616510500234550ustar00rootroot00000000000000import argparse import spotipy from spotipy.oauth2 import SpotifyOAuth def get_args(): parser = argparse.ArgumentParser(description='Follows a playlist based on playlist ID') parser.add_argument('-p', '--playlist', required=True, help='Playlist ID') return parser.parse_args() def main(): args = get_args() if args.playlist is None: # Uses the Spotify Global Top 50 playlist spotipy.Spotify(auth_manager=SpotifyOAuth()).current_user_follow_playlist( '37i9dQZEVXbMDoHDwVN2tF') else: spotipy.Spotify(auth_manager=SpotifyOAuth()).current_user_follow_playlist(args.playlist) if __name__ == '__main__': main() spotipy-dev-spotipy-8a40e03/examples/headless.py000066400000000000000000000003571462616510500220230ustar00rootroot00000000000000import spotipy from spotipy.oauth2 import SpotifyOAuth # set open_browser=False to prevent Spotipy from attempting to open the default browser spotify = spotipy.Spotify(auth_manager=SpotifyOAuth(open_browser=False)) print(spotify.me()) spotipy-dev-spotipy-8a40e03/examples/multiple_accounts.py000066400000000000000000000004041462616510500237560ustar00rootroot00000000000000import spotipy import spotipy.util as util from pprint import pprint while True: username = input("Type the Spotify user ID to use: ") token = util.prompt_for_user_token(username, show_dialog=True) sp = spotipy.Spotify(token) pprint(sp.me()) spotipy-dev-spotipy-8a40e03/examples/my_playlists.py000066400000000000000000000004631462616510500227620ustar00rootroot00000000000000# Shows a user's playlists import spotipy from spotipy.oauth2 import SpotifyOAuth scope = 'playlist-read-private' sp = spotipy.Spotify(auth_manager=SpotifyOAuth(scope=scope)) results = sp.current_user_playlists(limit=50) for i, item in enumerate(results['items']): print("%d %s" % (i, item['name'])) spotipy-dev-spotipy-8a40e03/examples/my_top_artists.py000066400000000000000000000007461462616510500233150ustar00rootroot00000000000000# Shows the top artists for a user import spotipy from spotipy.oauth2 import SpotifyOAuth scope = 'user-top-read' ranges = ['short_term', 'medium_term', 'long_term'] sp = spotipy.Spotify(auth_manager=SpotifyOAuth(scope=scope)) for sp_range in ['short_term', 'medium_term', 'long_term']: print("range:", sp_range) results = sp.current_user_top_artists(time_range=sp_range, limit=50) for i, item in enumerate(results['items']): print(i, item['name']) print() spotipy-dev-spotipy-8a40e03/examples/my_top_tracks.py000066400000000000000000000007411462616510500231060ustar00rootroot00000000000000# Shows the top tracks for a user import spotipy from spotipy.oauth2 import SpotifyOAuth scope = 'user-top-read' sp = spotipy.Spotify(auth_manager=SpotifyOAuth(scope=scope)) ranges = ['short_term', 'medium_term', 'long_term'] for sp_range in ranges: print("range:", sp_range) results = sp.current_user_top_tracks(time_range=sp_range, limit=50) for i, item in enumerate(results['items']): print(i, item['name'], '//', item['artists'][0]['name']) print() spotipy-dev-spotipy-8a40e03/examples/player.py000066400000000000000000000007121462616510500215220ustar00rootroot00000000000000import spotipy from spotipy.oauth2 import SpotifyOAuth from pprint import pprint from time import sleep scope = "user-read-playback-state,user-modify-playback-state" sp = spotipy.Spotify(client_credentials_manager=SpotifyOAuth(scope=scope)) # Shows playing devices res = sp.devices() pprint(res) # Change track sp.start_playback(uris=['spotify:track:6gdLoMygLsgktydTQ71b15']) # Change volume sp.volume(100) sleep(2) sp.volume(50) sleep(2) sp.volume(100) spotipy-dev-spotipy-8a40e03/examples/playlist_add_items.py000066400000000000000000000010521462616510500240760ustar00rootroot00000000000000# Add a list of items (URI) to a playlist (URI) import spotipy from spotipy.oauth2 import SpotifyOAuth sp = spotipy.Spotify(auth_manager=SpotifyOAuth(client_id="YOUR_APP_CLIENT_ID", client_secret="YOUR_APP_CLIENT_SECRET", redirect_uri="YOUR_APP_REDIRECT_URI", scope="playlist-modify-private" )) sp.playlist_add_items('playlist_id', ['list_of_items']) spotipy-dev-spotipy-8a40e03/examples/playlist_all_non_local_tracks.py000066400000000000000000000015111462616510500263100ustar00rootroot00000000000000# get all non-local tracks of a playlist from spotipy.oauth2 import SpotifyClientCredentials import spotipy # playlist id of global top 50 PlaylistExample = '37i9dQZEVXbMDoHDwVN2tF' # create spotipy client sp = spotipy.Spotify(client_credentials_manager=SpotifyClientCredentials()) # load the first 100 songs tracks = [] result = sp.playlist_items(PlaylistExample, additional_types=['track']) tracks.extend(result['items']) # if playlist is larger than 100 songs, continue loading it until end while result['next']: result = sp.next(result) tracks.extend(result['items']) # remove all local songs i = 0 # just for counting how many tracks are local for item in tracks: if item['is_local']: tracks.remove(item) i += 1 # print result print("Playlist length: " + str(len(tracks)) + "\nExcluding: " + str(i)) spotipy-dev-spotipy-8a40e03/examples/playlist_tracks.py000066400000000000000000000011631462616510500234370ustar00rootroot00000000000000from spotipy.oauth2 import SpotifyClientCredentials import spotipy from pprint import pprint sp = spotipy.Spotify(client_credentials_manager=SpotifyClientCredentials()) pl_id = 'spotify:playlist:5RIbzhG2QqdkaP24iXLnZX' offset = 0 while True: response = sp.playlist_items(pl_id, offset=offset, fields='items.track.id,total', additional_types=['track']) if len(response['items']) == 0: break pprint(response['items']) offset = offset + len(response['items']) print(offset, "/", response['total']) spotipy-dev-spotipy-8a40e03/examples/read_a_playlist.py000066400000000000000000000005501462616510500233620ustar00rootroot00000000000000from spotipy.oauth2 import SpotifyClientCredentials import spotipy import json client_credentials_manager = SpotifyClientCredentials() sp = spotipy.Spotify(client_credentials_manager=client_credentials_manager) playlist_id = 'spotify:user:spotifycharts:playlist:37i9dQZEVXbJiZcmkrIHGU' results = sp.playlist(playlist_id) print(json.dumps(results, indent=4)) spotipy-dev-spotipy-8a40e03/examples/remove_specific_tracks_from_playlist.py000066400000000000000000000012431462616510500277030ustar00rootroot00000000000000# Removes tracks from a playlist import pprint import sys import spotipy from spotipy.oauth2 import SpotifyOAuth if len(sys.argv) > 2: playlist_id = sys.argv[1] track_ids_and_positions = sys.argv[2:] track_ids = [] for t_pos in sys.argv[2:]: tid, pos = t_pos.split(',') track_ids.append({"uri": tid, "positions": [int(pos)]}) else: print( f"Usage: {sys.argv[0]} playlist_id track_id,pos track_id,pos ...") sys.exit() scope = 'playlist-modify-public' sp = spotipy.Spotify(auth_manager=SpotifyOAuth(scope=scope)) results = sp.playlist_remove_specific_occurrences_of_items( playlist_id, track_ids) pprint.pprint(results) spotipy-dev-spotipy-8a40e03/examples/remove_tracks_from_playlist.py000066400000000000000000000007371462616510500260450ustar00rootroot00000000000000# Removes tracks from playlist import pprint import sys import spotipy from spotipy.oauth2 import SpotifyOAuth if len(sys.argv) > 2: playlist_id = sys.argv[2] track_ids = sys.argv[3:] else: print(f"Usage: {sys.argv[0]} playlist_id track_id ...") sys.exit() scope = 'playlist-modify-public' sp = spotipy.Spotify(auth_manager=SpotifyOAuth(scope=scope)) results = sp.playlist_remove_all_occurrences_of_items( playlist_id, track_ids) pprint.pprint(results) spotipy-dev-spotipy-8a40e03/examples/replace_tracks_in_playlist.py000066400000000000000000000007151462616510500256220ustar00rootroot00000000000000# Replaces all tracks in a playlist import pprint import sys import spotipy from spotipy.oauth2 import SpotifyOAuth if len(sys.argv) > 3: playlist_id = sys.argv[1] track_ids = sys.argv[2:] else: print(f"Usage: {sys.argv[0]} playlist_id track_id ...") sys.exit() scope = 'playlist-modify-public' sp = spotipy.Spotify(auth_manager=SpotifyOAuth(scope=scope)) results = sp.playlist_replace_items(playlist_id, track_ids) pprint.pprint(results) spotipy-dev-spotipy-8a40e03/examples/search.py000066400000000000000000000005331462616510500214740ustar00rootroot00000000000000# shows artist info for a URN or URL from spotipy.oauth2 import SpotifyClientCredentials import spotipy import sys import pprint if len(sys.argv) > 1: search_str = sys.argv[1] else: search_str = 'Radiohead' sp = spotipy.Spotify(client_credentials_manager=SpotifyClientCredentials()) result = sp.search(search_str) pprint.pprint(result) spotipy-dev-spotipy-8a40e03/examples/show_album.py000066400000000000000000000005421462616510500223670ustar00rootroot00000000000000# shows album info for a URN or URL from spotipy.oauth2 import SpotifyClientCredentials import spotipy import sys from pprint import pprint if len(sys.argv) > 1: urn = sys.argv[1] else: urn = 'spotify:album:5yTx83u3qerZF7GRJu7eFk' sp = spotipy.Spotify(client_credentials_manager=SpotifyClientCredentials()) album = sp.album(urn) pprint(album) spotipy-dev-spotipy-8a40e03/examples/show_artist.py000066400000000000000000000005501462616510500225740ustar00rootroot00000000000000# shows artist info for a URN or URL from spotipy.oauth2 import SpotifyClientCredentials import spotipy import sys from pprint import pprint if len(sys.argv) > 1: urn = sys.argv[1] else: urn = 'spotify:artist:3jOstUTkEu2JkjvRdBA5Gu' sp = spotipy.Spotify(client_credentials_manager=SpotifyClientCredentials()) artist = sp.artist(urn) pprint(artist) spotipy-dev-spotipy-8a40e03/examples/show_artist_top_tracks.py000066400000000000000000000006601462616510500250270ustar00rootroot00000000000000# shows artist info for a URN or URL # scope is not required for this function from spotipy.oauth2 import SpotifyClientCredentials import spotipy import sys if len(sys.argv) > 1: urn = sys.argv[1] else: urn = 'spotify:artist:3jOstUTkEu2JkjvRdBA5Gu' sp = spotipy.Spotify(client_credentials_manager=SpotifyClientCredentials()) response = sp.artist_top_tracks(urn) for track in response['tracks']: print(track['name']) spotipy-dev-spotipy-8a40e03/examples/show_featured_artists.py000066400000000000000000000011431462616510500246350ustar00rootroot00000000000000# Shows all artists featured on an album # usage: featured_artists.py spotify:album:[album urn] from spotipy.oauth2 import SpotifyClientCredentials import sys import spotipy from pprint import pprint if len(sys.argv) > 1: urn = sys.argv[1] else: urn = 'spotify:album:5yTx83u3qerZF7GRJu7eFk' sp = spotipy.Spotify(client_credentials_manager=SpotifyClientCredentials()) album = sp.album(urn) featured_artists = set() items = album['tracks']['items'] for item in items: for ele in item['artists']: if 'name' in ele: featured_artists.add(ele['name']) pprint(featured_artists) spotipy-dev-spotipy-8a40e03/examples/show_featured_playlists.py000066400000000000000000000007201462616510500251700ustar00rootroot00000000000000# shows artist info for a URN or URL import spotipy from spotipy.oauth2 import SpotifyOAuth sp = spotipy.Spotify(auth_manager=SpotifyOAuth()) response = sp.featured_playlists() print(response['message']) while response: playlists = response['playlists'] for i, item in enumerate(playlists['items']): print(playlists['offset'] + i, item['name']) if playlists['next']: response = sp.next(playlists) else: response = None spotipy-dev-spotipy-8a40e03/examples/show_my_saved_tracks.py000066400000000000000000000010151462616510500244410ustar00rootroot00000000000000# Shows a user's saved tracks (need to be authenticated via oauth) import spotipy from spotipy.oauth2 import SpotifyOAuth scope = 'user-library-read' def show_tracks(results): for item in results['items']: track = item['track'] print("%32.32s %s" % (track['artists'][0]['name'], track['name'])) sp = spotipy.Spotify(auth_manager=SpotifyOAuth(scope=scope)) results = sp.current_user_saved_tracks() show_tracks(results) while results['next']: results = sp.next(results) show_tracks(results) spotipy-dev-spotipy-8a40e03/examples/show_new_releases.py000066400000000000000000000006351462616510500237460ustar00rootroot00000000000000# shows artist info for a URN or URL import spotipy from spotipy.oauth2 import SpotifyOAuth sp = spotipy.Spotify(auth_manager=SpotifyOAuth()) response = sp.new_releases() while response: albums = response['albums'] for i, item in enumerate(albums['items']): print(albums['offset'] + i, item['name']) if albums['next']: response = sp.next(albums) else: response = None spotipy-dev-spotipy-8a40e03/examples/show_related.py000066400000000000000000000013441462616510500227100ustar00rootroot00000000000000# shows related artists for the given seed artist from spotipy.oauth2 import SpotifyClientCredentials import spotipy import sys if len(sys.argv) > 1: artist_name = sys.argv[1] else: artist_name = 'weezer' client_credentials_manager = SpotifyClientCredentials() sp = spotipy.Spotify(client_credentials_manager=client_credentials_manager) result = sp.search(q='artist:' + artist_name, type='artist') try: name = result['artists']['items'][0]['name'] uri = result['artists']['items'][0]['uri'] related = sp.artist_related_artists(uri) print('Related artists for', name) for artist in related['artists']: print(' ', artist['name']) except BaseException: print("usage show_related.py [artist-name]") spotipy-dev-spotipy-8a40e03/examples/show_track_info.py000066400000000000000000000005431462616510500234070ustar00rootroot00000000000000# shows track info for a URN or URL from spotipy.oauth2 import SpotifyClientCredentials import spotipy import sys from pprint import pprint if len(sys.argv) > 1: urn = sys.argv[1] else: urn = 'spotify:track:0Svkvt5I79wficMFgaqEQJ' sp = spotipy.Spotify(client_credentials_manager=SpotifyClientCredentials()) track = sp.track(urn) pprint(track) spotipy-dev-spotipy-8a40e03/examples/show_tracks.py000066400000000000000000000013551462616510500225610ustar00rootroot00000000000000''' usage: show_tracks.py path_of_ids given a list of track IDs show the artist and track name ''' from spotipy.oauth2 import SpotifyClientCredentials import sys import spotipy if __name__ == '__main__': max_tracks_per_call = 50 if len(sys.argv) > 1: file = open(sys.argv[1]) else: file = sys.stdin tids = file.read().split() client_credentials_manager = SpotifyClientCredentials() sp = spotipy.Spotify(client_credentials_manager=client_credentials_manager) for start in range(0, len(tids), max_tracks_per_call): results = sp.tracks(tids[start: start + max_tracks_per_call]) for track in results['tracks']: print(track['name'] + ' - ' + track['artists'][0]['name']) spotipy-dev-spotipy-8a40e03/examples/show_user.py000066400000000000000000000006251462616510500222470ustar00rootroot00000000000000# Shows artist info for a URN or URL from spotipy.oauth2 import SpotifyClientCredentials import spotipy import sys import pprint if len(sys.argv) > 1: username = sys.argv[1] else: username = 'plamere' client_credentials_manager = SpotifyClientCredentials() sp = spotipy.Spotify(client_credentials_manager=client_credentials_manager) sp.trace = True user = sp.user(username) pprint.pprint(user) spotipy-dev-spotipy-8a40e03/examples/simple_artist_albums.py000066400000000000000000000007331462616510500244530ustar00rootroot00000000000000from spotipy.oauth2 import SpotifyClientCredentials import spotipy birdy_uri = 'spotify:artist:2WX2uTcsvV5OnS0inACecP' client_credentials_manager = SpotifyClientCredentials() sp = spotipy.Spotify(client_credentials_manager=client_credentials_manager) results = sp.artist_albums(birdy_uri, album_type='album') albums = results['items'] while results['next']: results = sp.next(results) albums.extend(results['items']) for album in albums: print(album['name']) spotipy-dev-spotipy-8a40e03/examples/simple_artist_top_tracks.py000066400000000000000000000007371462616510500253450ustar00rootroot00000000000000from spotipy.oauth2 import SpotifyClientCredentials import spotipy lz_uri = 'spotify:artist:36QJpDe2go2KgaRleHCDTp' client_credentials_manager = SpotifyClientCredentials() sp = spotipy.Spotify(client_credentials_manager=client_credentials_manager) results = sp.artist_top_tracks(lz_uri) for track in results['tracks'][:10]: print('track : ' + track['name']) print('audio : ' + track['preview_url']) print('cover art: ' + track['album']['images'][0]['url']) spotipy-dev-spotipy-8a40e03/examples/simple_me.py000066400000000000000000000003071462616510500222000ustar00rootroot00000000000000import spotipy from pprint import pprint def main(): spotify = spotipy.Spotify(auth_manager=spotipy.SpotifyOAuth()) me = spotify.me() pprint(me) if __name__ == "__main__": main() spotipy-dev-spotipy-8a40e03/examples/simple_search_artist.py000066400000000000000000000005031462616510500244300ustar00rootroot00000000000000from spotipy.oauth2 import SpotifyClientCredentials import spotipy client_credentials_manager = SpotifyClientCredentials() sp = spotipy.Spotify(client_credentials_manager=client_credentials_manager) results = sp.search(q='weezer', limit=20) for i, t in enumerate(results['tracks']['items']): print(' ', i, t['name']) spotipy-dev-spotipy-8a40e03/examples/simple_search_artist_image_url.py000066400000000000000000000007621462616510500264630ustar00rootroot00000000000000# Shows the name of the artist/band and their image by giving a link import sys from spotipy.oauth2 import SpotifyClientCredentials import spotipy sp = spotipy.Spotify(client_credentials_manager=SpotifyClientCredentials()) if len(sys.argv) > 1: name = ' '.join(sys.argv[1:]) else: name = 'Radiohead' results = sp.search(q='artist:' + name, type='artist') items = results['artists']['items'] if len(items) > 0: artist = items[0] print(artist['name'], artist['images'][0]['url']) spotipy-dev-spotipy-8a40e03/examples/test.py000066400000000000000000000005101462616510500212010ustar00rootroot00000000000000import spotipy from spotipy.oauth2 import SpotifyOAuth scope = "user-library-read" sp = spotipy.Spotify(auth_manager=SpotifyOAuth(scope=scope)) results = sp.current_user_saved_tracks() for idx, item in enumerate(results['items']): track = item['track'] print(idx, track['artists'][0]['name'], " – ", track['name']) spotipy-dev-spotipy-8a40e03/examples/title_chain.py000066400000000000000000000035121462616510500225120ustar00rootroot00000000000000from spotipy.oauth2 import SpotifyClientCredentials import spotipy import random ''' generates a list of songs where the first word in each subsequent song matches the last word of the previous song. usage: python title_chain.py [song name] ''' client_credentials_manager = SpotifyClientCredentials() sp = spotipy.Spotify(client_credentials_manager=client_credentials_manager) skiplist = {'dm', 'remix'} max_offset = 500 seen = set() def find_songs_that_start_with_word(word): max_titles = 20 max_offset = 200 offset = 0 out = [] while offset < max_offset and len(out) < max_titles: results = sp.search(q=word, type='track', limit=50, offset=offset) if len(results['tracks']['items']) == 0: break for item in results['tracks']['items']: name = item['name'].lower() if name in seen: continue seen.add(name) if '(' in name: continue if '-' in name: continue if '/' in name: continue words = name.split() if len(words) > 1 and words[0] == word \ and words[-1] not in skiplist: # print " ", name, len(out) out.append(item) offset += 50 # print "found", len(out), "matches" return out def make_chain(word): which = 1 while True: songs = find_songs_that_start_with_word(word) if len(songs) > 0: song = random.choice(songs) print(which, song['name'] + " by " + song['artists'][0]['name']) which += 1 word = song['name'].lower().split()[-1] else: break if __name__ == '__main__': import sys title = ' '.join(sys.argv[1:]) make_chain(sys.argv[1].lower()) spotipy-dev-spotipy-8a40e03/examples/tracks.py000066400000000000000000000007521462616510500215210ustar00rootroot00000000000000# shows tracks for the given artist # usage: python tracks.py [artist name] from spotipy.oauth2 import SpotifyClientCredentials import spotipy import sys client_credentials_manager = SpotifyClientCredentials() sp = spotipy.Spotify(client_credentials_manager=client_credentials_manager) if len(sys.argv) > 1: artist_name = ' '.join(sys.argv[1:]) results = sp.search(q=artist_name, limit=20) for i, t in enumerate(results['tracks']['items']): print(' ', i, t['name']) spotipy-dev-spotipy-8a40e03/examples/unfollow_playlist.py000066400000000000000000000015511462616510500240160ustar00rootroot00000000000000import argparse import logging import spotipy from spotipy.oauth2 import SpotifyOAuth logger = logging.getLogger('examples.unfollow_playlist') logging.basicConfig(level='DEBUG') ''' Spotify doesn't have a dedicated endpoint for deleting a playlist. However, unfollowing a playlist has the effect of deleting it from the user's account. When a playlist is removed from the user's account, the system unfollows it, and then no longer shows it in playlist list.''' def get_args(): parser = argparse.ArgumentParser(description='Unfollows a playlist') parser.add_argument('-p', '--playlist', required=True, help='Playlist id') return parser.parse_args() def main(): args = get_args() sp = spotipy.Spotify(auth_manager=SpotifyOAuth()) sp.current_user_unfollow_playlist(args.playlist) if __name__ == '__main__': main() spotipy-dev-spotipy-8a40e03/examples/user_playlists.py000066400000000000000000000007051462616510500233120ustar00rootroot00000000000000# Shows a user's playlists (need to be authenticated via oauth) import sys import spotipy from spotipy.oauth2 import SpotifyOAuth if len(sys.argv) > 1: username = sys.argv[1] else: print("Whoops, need a username!") print("usage: python user_playlists.py [username]") sys.exit() sp = spotipy.Spotify(auth_manager=SpotifyOAuth()) playlists = sp.user_playlists(username) for playlist in playlists['items']: print(playlist['name']) spotipy-dev-spotipy-8a40e03/examples/user_playlists_contents.py000066400000000000000000000017471462616510500252360ustar00rootroot00000000000000# Shows a user's playlists (need to be authenticated via oauth) import spotipy from spotipy.oauth2 import SpotifyOAuth def show_tracks(results): for i, item in enumerate(results['items']): track = item['track'] print( " %d %32.32s %s" % (i, track['artists'][0]['name'], track['name'])) if __name__ == '__main__': scope = 'playlist-read-private' sp = spotipy.Spotify(auth_manager=SpotifyOAuth(scope=scope)) playlists = sp.current_user_playlists() user_id = sp.me()['id'] for playlist in playlists['items']: if playlist['owner']['id'] == user_id: print() print(playlist['name']) print(' total tracks', playlist['tracks']['total']) tracks = sp.playlist_items(playlist['id'], fields="items,next", additional_types=('tracks', )) show_tracks(tracks) while tracks['next']: tracks = sp.next(tracks) show_tracks(tracks) spotipy-dev-spotipy-8a40e03/examples/user_public_playlists.py000066400000000000000000000013451462616510500246510ustar00rootroot00000000000000# Gets all the public playlists for the given # user. Uses Client Credentials flow # import sys import spotipy from spotipy.oauth2 import SpotifyClientCredentials client_credentials_manager = SpotifyClientCredentials() sp = spotipy.Spotify(client_credentials_manager=client_credentials_manager) user = 'spotify' if len(sys.argv) > 1: user = sys.argv[1] playlists = sp.user_playlists(user) while playlists: for i, playlist in enumerate(playlists['items']): print( "%4d %s %s" % (i + 1 + playlists['offset'], playlist['uri'], playlist['name'])) if playlists['next']: playlists = sp.next(playlists) else: playlists = None spotipy-dev-spotipy-8a40e03/examples/user_saved_albums_delete.py000066400000000000000000000005401462616510500252520ustar00rootroot00000000000000# Deletes user saved album import spotipy from spotipy.oauth2 import SpotifyOAuth scope = 'user-library-modify' sp = spotipy.Spotify(auth_manager=SpotifyOAuth(scope=scope)) uris = input("input a list of album URIs, URLs or IDs: ") uris = list(map(str, uris.split())) deleted = sp.current_user_saved_albums_delete(uris) print("Deletion successful.") spotipy-dev-spotipy-8a40e03/setup.py000066400000000000000000000016451462616510500175560ustar00rootroot00000000000000from setuptools import setup with open("README.md") as f: long_description = f.read() test_reqs = [ 'mock==2.0.0' ] memcache_cache_reqs = [ 'pymemcache>=3.5.2' ] extra_reqs = { 'test': test_reqs, 'memcache': memcache_cache_reqs } setup( name='spotipy', version='2.24.0', description='A light weight Python library for the Spotify Web API', long_description=long_description, long_description_content_type="text/markdown", author="@plamere", author_email="paul@echonest.com", url='https://spotipy.readthedocs.org/', project_urls={ 'Source': 'https://github.com/plamere/spotipy', }, python_requires='>3.8', install_requires=[ "redis>=3.5.3", # TODO: Move to extras_require in v3 "requests>=2.25.0", "urllib3>=1.26.0" ], tests_require=test_reqs, extras_require=extra_reqs, license='MIT', packages=['spotipy']) spotipy-dev-spotipy-8a40e03/spotipy/000077500000000000000000000000001462616510500175455ustar00rootroot00000000000000spotipy-dev-spotipy-8a40e03/spotipy/__init__.py000066400000000000000000000002371462616510500216600ustar00rootroot00000000000000from .cache_handler import * # noqa from .client import * # noqa from .exceptions import * # noqa from .oauth2 import * # noqa from .util import * # noqa spotipy-dev-spotipy-8a40e03/spotipy/cache_handler.py000066400000000000000000000163501462616510500226640ustar00rootroot00000000000000__all__ = [ 'CacheHandler', 'CacheFileHandler', 'DjangoSessionCacheHandler', 'FlaskSessionCacheHandler', 'MemoryCacheHandler', 'RedisCacheHandler', 'MemcacheCacheHandler'] import errno import json import logging import os from spotipy.util import CLIENT_CREDS_ENV_VARS from redis import RedisError logger = logging.getLogger(__name__) class CacheHandler(): """ An abstraction layer for handling the caching and retrieval of authorization tokens. Custom extensions of this class must implement get_cached_token and save_token_to_cache methods with the same input and output structure as the CacheHandler class. """ def get_cached_token(self): """ Get and return a token_info dictionary object. """ # return token_info raise NotImplementedError() def save_token_to_cache(self, token_info): """ Save a token_info dictionary object to the cache and return None. """ raise NotImplementedError() return None class CacheFileHandler(CacheHandler): """ Handles reading and writing cached Spotify authorization tokens as json files on disk. """ def __init__(self, cache_path=None, username=None, encoder_cls=None): """ Parameters: * cache_path: May be supplied, will otherwise be generated (takes precedence over `username`) * username: May be supplied or set as environment variable (will set `cache_path` to `.cache-{username}`) * encoder_cls: May be supplied as a means of overwriting the default serializer used for writing tokens to disk """ self.encoder_cls = encoder_cls if cache_path: self.cache_path = cache_path else: cache_path = ".cache" username = (username or os.getenv(CLIENT_CREDS_ENV_VARS["client_username"])) if username: cache_path += "-" + str(username) self.cache_path = cache_path def get_cached_token(self): token_info = None try: f = open(self.cache_path) token_info_string = f.read() f.close() token_info = json.loads(token_info_string) except OSError as error: if error.errno == errno.ENOENT: logger.debug("cache does not exist at: %s", self.cache_path) else: logger.warning("Couldn't read cache at: %s", self.cache_path) return token_info def save_token_to_cache(self, token_info): try: f = open(self.cache_path, "w") f.write(json.dumps(token_info, cls=self.encoder_cls)) f.close() except OSError: logger.warning('Couldn\'t write token to cache at: %s', self.cache_path) class MemoryCacheHandler(CacheHandler): """ A cache handler that simply stores the token info in memory as an instance attribute of this class. The token info will be lost when this instance is freed. """ def __init__(self, token_info=None): """ Parameters: * token_info: The token info to store in memory. Can be None. """ self.token_info = token_info def get_cached_token(self): return self.token_info def save_token_to_cache(self, token_info): self.token_info = token_info class DjangoSessionCacheHandler(CacheHandler): """ A cache handler that stores the token info in the session framework provided by Django. Read more at https://docs.djangoproject.com/en/3.2/topics/http/sessions/ """ def __init__(self, request): """ Parameters: * request: HttpRequest object provided by Django for every incoming request """ self.request = request def get_cached_token(self): token_info = None try: token_info = self.request.session['token_info'] except KeyError: logger.debug("Token not found in the session") return token_info def save_token_to_cache(self, token_info): try: self.request.session['token_info'] = token_info except Exception as e: logger.warning("Error saving token to cache: " + str(e)) class FlaskSessionCacheHandler(CacheHandler): """ A cache handler that stores the token info in the session framework provided by flask. """ def __init__(self, session): self.session = session def get_cached_token(self): token_info = None try: token_info = self.session["token_info"] except KeyError: logger.debug("Token not found in the session") return token_info def save_token_to_cache(self, token_info): try: self.session["token_info"] = token_info except Exception as e: logger.warning("Error saving token to cache: " + str(e)) class RedisCacheHandler(CacheHandler): """ A cache handler that stores the token info in the Redis. """ def __init__(self, redis, key=None): """ Parameters: * redis: Redis object provided by redis-py library (https://github.com/redis/redis-py) * key: May be supplied, will otherwise be generated (takes precedence over `token_info`) """ self.redis = redis self.key = key if key else 'token_info' def get_cached_token(self): token_info = None try: token_info = self.redis.get(self.key) if token_info: return json.loads(token_info) except RedisError as e: logger.warning('Error getting token from cache: ' + str(e)) return token_info def save_token_to_cache(self, token_info): try: self.redis.set(self.key, json.dumps(token_info)) except RedisError as e: logger.warning('Error saving token to cache: ' + str(e)) class MemcacheCacheHandler(CacheHandler): """A Cache handler that stores the token info in Memcache using the pymemcache client """ def __init__(self, memcache, key=None) -> None: """ Parameters: * memcache: memcache client object provided by pymemcache (https://pymemcache.readthedocs.io/en/latest/getting_started.html) * key: May be supplied, will otherwise be generated (takes precedence over `token_info`) """ self.memcache = memcache self.key = key if key else 'token_info' def get_cached_token(self): from pymemcache import MemcacheError try: token_info = self.memcache.get(self.key) if token_info: return json.loads(token_info.decode()) except MemcacheError as e: logger.warning('Error getting token from cache' + str(e)) def save_token_to_cache(self, token_info): from pymemcache import MemcacheError try: self.memcache.set(self.key, json.dumps(token_info)) except MemcacheError as e: logger.warning('Error saving token to cache' + str(e)) spotipy-dev-spotipy-8a40e03/spotipy/client.py000066400000000000000000002275751462616510500214170ustar00rootroot00000000000000""" A simple and thin Python library for the Spotify Web API """ __all__ = ["Spotify", "SpotifyException"] import json import logging import re import warnings import requests import urllib3 from spotipy.exceptions import SpotifyException from collections import defaultdict logger = logging.getLogger(__name__) class Spotify: """ Example usage:: import spotipy urn = 'spotify:artist:3jOstUTkEu2JkjvRdBA5Gu' sp = spotipy.Spotify() artist = sp.artist(urn) print(artist) user = sp.user('plamere') print(user) """ max_retries = 3 default_retry_codes = (429, 500, 502, 503, 504) country_codes = [ "AD", "AR", "AU", "AT", "BE", "BO", "BR", "BG", "CA", "CL", "CO", "CR", "CY", "CZ", "DK", "DO", "EC", "SV", "EE", "FI", "FR", "DE", "GR", "GT", "HN", "HK", "HU", "IS", "ID", "IE", "IT", "JP", "LV", "LI", "LT", "LU", "MY", "MT", "MX", "MC", "NL", "NZ", "NI", "NO", "PA", "PY", "PE", "PH", "PL", "PT", "SG", "ES", "SK", "SE", "CH", "TW", "TR", "GB", "US", "UY"] # Spotify URI scheme defined in [1], and the ID format as base-62 in [2]. # # Unfortunately the IANA specification is out of date and doesn't include the new types # show and episode. Additionally, for the user URI, it does not specify which characters # are valid for usernames, so the assumption is alphanumeric which coincidentally are also # the same ones base-62 uses. # In limited manual exploration this seems to hold true, as newly accounts are assigned an # identifier that looks like the base-62 of all other IDs, but some older accounts only have # numbers and even older ones seemed to have been allowed to freely pick this name. # # [1] https://www.iana.org/assignments/uri-schemes/prov/spotify # [2] https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids _regex_spotify_uri = r'^spotify:(?:(?Ptrack|artist|album|playlist|show|episode|audiobook):(?P[0-9A-Za-z]+)|user:(?P[0-9A-Za-z]+):playlist:(?P[0-9A-Za-z]+))$' # noqa: E501 # Spotify URLs are defined at [1]. The assumption is made that they are all # pointing to open.spotify.com, so a regex is used to parse them as well, # instead of a more complex URL parsing function. # Spotify recently added "/intl-" to their links. This change is undocumented. # There is an assumption that the country code uses the ISO 3166-1 alpha-2 standard [2], # but this has not been confirmed yet. Spotipy has no use for this, so it gets ignored. # # [1] https://developer.spotify.com/documentation/web-api/concepts/spotify-uris-ids # [2] https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2 _regex_spotify_url = r'^(http[s]?:\/\/)?open.spotify.com\/(intl-\w\w\/)?(?Ptrack|artist|album|playlist|show|episode|user|audiobook)\/(?P[0-9A-Za-z]+)(\?.*)?$' # noqa: E501 _regex_base62 = r'^[0-9A-Za-z]+$' def __init__( self, auth=None, requests_session=True, client_credentials_manager=None, oauth_manager=None, auth_manager=None, proxies=None, requests_timeout=5, status_forcelist=None, retries=max_retries, status_retries=max_retries, backoff_factor=0.3, language=None, ): """ Creates a Spotify API client. :param auth: An access token (optional) :param requests_session: A Requests session object or a truthy value to create one. A falsy value disables sessions. It should generally be a good idea to keep sessions enabled for performance reasons (connection pooling). :param client_credentials_manager: SpotifyClientCredentials object :param oauth_manager: SpotifyOAuth object :param auth_manager: SpotifyOauth, SpotifyClientCredentials, or SpotifyImplicitGrant object :param proxies: Definition of proxies (optional). See Requests doc https://2.python-requests.org/en/master/user/advanced/#proxies :param requests_timeout: Tell Requests to stop waiting for a response after a given number of seconds :param status_forcelist: Tell requests what type of status codes retries should occur on :param retries: Total number of retries to allow :param status_retries: Number of times to retry on bad status codes :param backoff_factor: A backoff factor to apply between attempts after the second try See urllib3 https://urllib3.readthedocs.io/en/latest/reference/urllib3.util.html :param language: The language parameter advertises what language the user prefers to see. See ISO-639-1 language code: https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes """ self.prefix = "https://api.spotify.com/v1/" self._auth = auth self.client_credentials_manager = client_credentials_manager self.oauth_manager = oauth_manager self.auth_manager = auth_manager self.proxies = proxies self.requests_timeout = requests_timeout self.status_forcelist = status_forcelist or self.default_retry_codes self.backoff_factor = backoff_factor self.retries = retries self.status_retries = status_retries self.language = language if isinstance(requests_session, requests.Session): self._session = requests_session else: if requests_session: # Build a new session. self._build_session() else: # Use the Requests API module as a "session". self._session = requests.api def set_auth(self, auth): self._auth = auth @property def auth_manager(self): return self._auth_manager @auth_manager.setter def auth_manager(self, auth_manager): if auth_manager is not None: self._auth_manager = auth_manager else: self._auth_manager = ( self.client_credentials_manager or self.oauth_manager ) def __del__(self): """Make sure the connection (pool) gets closed""" try: if isinstance(self._session, requests.Session): self._session.close() except AttributeError: pass def _build_session(self): self._session = requests.Session() retry = urllib3.Retry( total=self.retries, connect=None, read=False, allowed_methods=frozenset(['GET', 'POST', 'PUT', 'DELETE']), status=self.status_retries, backoff_factor=self.backoff_factor, status_forcelist=self.status_forcelist) adapter = requests.adapters.HTTPAdapter(max_retries=retry) self._session.mount('http://', adapter) self._session.mount('https://', adapter) def _auth_headers(self): if self._auth: return {"Authorization": f"Bearer {self._auth}"} if not self.auth_manager: return {} try: token = self.auth_manager.get_access_token(as_dict=False) except TypeError: token = self.auth_manager.get_access_token() return {"Authorization": f"Bearer {token}"} def _internal_call(self, method, url, payload, params): args = dict(params=params) if not url.startswith("http"): url = self.prefix + url headers = self._auth_headers() if "content_type" in args["params"]: headers["Content-Type"] = args["params"]["content_type"] del args["params"]["content_type"] if payload: args["data"] = payload else: headers["Content-Type"] = "application/json" if payload: args["data"] = json.dumps(payload) if self.language is not None: headers["Accept-Language"] = self.language logger.debug('Sending %s to %s with Params: %s Headers: %s and Body: %r ', method, url, args.get("params"), headers, args.get('data')) try: response = self._session.request( method, url, headers=headers, proxies=self.proxies, timeout=self.requests_timeout, **args ) response.raise_for_status() results = response.json() except requests.exceptions.HTTPError as http_error: response = http_error.response try: json_response = response.json() error = json_response.get("error", {}) msg = error.get("message") reason = error.get("reason") except ValueError: # if the response cannot be decoded into JSON (which raises a ValueError), # then try to decode it into text # if we receive an empty string (which is falsy), then replace it with `None` msg = response.text or None reason = None logger.error( 'HTTP Error for %s to %s with Params: %s returned %s due to %s', method, url, args.get("params"), response.status_code, msg ) raise SpotifyException( response.status_code, -1, f"{response.url}:\n {msg}", reason=reason, headers=response.headers, ) except requests.exceptions.RetryError as retry_error: request = retry_error.request logger.error('Max Retries reached') try: reason = retry_error.args[0].reason except (IndexError, AttributeError): reason = None raise SpotifyException( 429, -1, f"{request.path_url}:\n Max Retries", reason=reason ) except ValueError: results = None logger.debug('RESULTS: %s', results) return results def _get(self, url, args=None, payload=None, **kwargs): if args: kwargs.update(args) return self._internal_call("GET", url, payload, kwargs) def _post(self, url, args=None, payload=None, **kwargs): if args: kwargs.update(args) return self._internal_call("POST", url, payload, kwargs) def _delete(self, url, args=None, payload=None, **kwargs): if args: kwargs.update(args) return self._internal_call("DELETE", url, payload, kwargs) def _put(self, url, args=None, payload=None, **kwargs): if args: kwargs.update(args) return self._internal_call("PUT", url, payload, kwargs) def next(self, result): """ returns the next result given a paged result Parameters: - result - a previously returned paged result """ if result["next"]: return self._get(result["next"]) else: return None def previous(self, result): """ returns the previous result given a paged result Parameters: - result - a previously returned paged result """ if result["previous"]: return self._get(result["previous"]) else: return None def track(self, track_id, market=None): """ returns a single track given the track's ID, URI or URL Parameters: - track_id - a spotify URI, URL or ID - market - an ISO 3166-1 alpha-2 country code. """ trid = self._get_id("track", track_id) return self._get("tracks/" + trid, market=market) def tracks(self, tracks, market=None): """ returns a list of tracks given a list of track IDs, URIs, or URLs Parameters: - tracks - a list of spotify URIs, URLs or IDs. Maximum: 50 IDs. - market - an ISO 3166-1 alpha-2 country code. """ tlist = [self._get_id("track", t) for t in tracks] return self._get("tracks/?ids=" + ",".join(tlist), market=market) def artist(self, artist_id): """ returns a single artist given the artist's ID, URI or URL Parameters: - artist_id - an artist ID, URI or URL """ trid = self._get_id("artist", artist_id) return self._get("artists/" + trid) def artists(self, artists): """ returns a list of artists given the artist IDs, URIs, or URLs Parameters: - artists - a list of artist IDs, URIs or URLs """ tlist = [self._get_id("artist", a) for a in artists] return self._get("artists/?ids=" + ",".join(tlist)) def artist_albums( self, artist_id, album_type=None, include_groups=None, country=None, limit=20, offset=0 ): """ Get Spotify catalog information about an artist's albums Parameters: - artist_id - the artist ID, URI or URL - include_groups - the types of items to return. One or more of 'album', 'single', 'appears_on', 'compilation'. If multiple types are desired, pass in a comma separated string; e.g., 'album,single'. - country - limit the response to one particular country. - limit - the number of albums to return - offset - the index of the first album to return """ if album_type: warnings.warn( "You're using `artist_albums(..., album_type='...')` which will be removed in " "future versions. Please adjust your code accordingly by using " "`artist_albums(..., include_groups='...')` instead.", DeprecationWarning, ) include_groups = include_groups or album_type trid = self._get_id("artist", artist_id) return self._get( "artists/" + trid + "/albums", include_groups=include_groups, country=country, limit=limit, offset=offset, ) def artist_top_tracks(self, artist_id, country="US"): """ Get Spotify catalog information about an artist's top 10 tracks by country. Parameters: - artist_id - the artist ID, URI or URL - country - limit the response to one particular country. """ trid = self._get_id("artist", artist_id) return self._get("artists/" + trid + "/top-tracks", country=country) def artist_related_artists(self, artist_id): """ Get Spotify catalog information about artists similar to an identified artist. Similarity is based on analysis of the Spotify community's listening history. Parameters: - artist_id - the artist ID, URI or URL """ trid = self._get_id("artist", artist_id) return self._get("artists/" + trid + "/related-artists") def album(self, album_id, market=None): """ returns a single album given the album's ID, URIs or URL Parameters: - album_id - the album ID, URI or URL - market - an ISO 3166-1 alpha-2 country code """ trid = self._get_id("album", album_id) if market is not None: return self._get("albums/" + trid + '?market=' + market) else: return self._get("albums/" + trid) def album_tracks(self, album_id, limit=50, offset=0, market=None): """ Get Spotify catalog information about an album's tracks Parameters: - album_id - the album ID, URI or URL - limit - the number of items to return - offset - the index of the first item to return - market - an ISO 3166-1 alpha-2 country code. """ trid = self._get_id("album", album_id) return self._get( "albums/" + trid + "/tracks/", limit=limit, offset=offset, market=market ) def albums(self, albums, market=None): """ returns a list of albums given the album IDs, URIs, or URLs Parameters: - albums - a list of album IDs, URIs or URLs - market - an ISO 3166-1 alpha-2 country code """ tlist = [self._get_id("album", a) for a in albums] if market is not None: return self._get("albums/?ids=" + ",".join(tlist) + '&market=' + market) else: return self._get("albums/?ids=" + ",".join(tlist)) def show(self, show_id, market=None): """ returns a single show given the show's ID, URIs or URL Parameters: - show_id - the show ID, URI or URL - market - an ISO 3166-1 alpha-2 country code. The show must be available in the given market. If user-based authorization is in use, the user's country takes precedence. If neither market nor user country are provided, the content is considered unavailable for the client. """ trid = self._get_id("show", show_id) return self._get("shows/" + trid, market=market) def shows(self, shows, market=None): """ returns a list of shows given the show IDs, URIs, or URLs Parameters: - shows - a list of show IDs, URIs or URLs - market - an ISO 3166-1 alpha-2 country code. Only shows available in the given market will be returned. If user-based authorization is in use, the user's country takes precedence. If neither market nor user country are provided, the content is considered unavailable for the client. """ tlist = [self._get_id("show", s) for s in shows] return self._get("shows/?ids=" + ",".join(tlist), market=market) def show_episodes(self, show_id, limit=50, offset=0, market=None): """ Get Spotify catalog information about a show's episodes Parameters: - show_id - the show ID, URI or URL - limit - the number of items to return - offset - the index of the first item to return - market - an ISO 3166-1 alpha-2 country code. Only episodes available in the given market will be returned. If user-based authorization is in use, the user's country takes precedence. If neither market nor user country are provided, the content is considered unavailable for the client. """ trid = self._get_id("show", show_id) return self._get( "shows/" + trid + "/episodes/", limit=limit, offset=offset, market=market ) def episode(self, episode_id, market=None): """ returns a single episode given the episode's ID, URIs or URL Parameters: - episode_id - the episode ID, URI or URL - market - an ISO 3166-1 alpha-2 country code. The episode must be available in the given market. If user-based authorization is in use, the user's country takes precedence. If neither market nor user country are provided, the content is considered unavailable for the client. """ trid = self._get_id("episode", episode_id) return self._get("episodes/" + trid, market=market) def episodes(self, episodes, market=None): """ returns a list of episodes given the episode IDs, URIs, or URLs Parameters: - episodes - a list of episode IDs, URIs or URLs - market - an ISO 3166-1 alpha-2 country code. Only episodes available in the given market will be returned. If user-based authorization is in use, the user's country takes precedence. If neither market nor user country are provided, the content is considered unavailable for the client. """ tlist = [self._get_id("episode", e) for e in episodes] return self._get("episodes/?ids=" + ",".join(tlist), market=market) def search(self, q, limit=10, offset=0, type="track", market=None): """ searches for an item Parameters: - q - the search query (see how to write a query in the official documentation https://developer.spotify.com/documentation/web-api/reference/search/) # noqa - limit - the number of items to return (min = 1, default = 10, max = 50). The limit is applied within each type, not on the total response. - offset - the index of the first item to return - type - the types of items to return. One or more of 'artist', 'album', 'track', 'playlist', 'show', and 'episode'. If multiple types are desired, pass in a comma separated string; e.g., 'track,album,episode'. - market - An ISO 3166-1 alpha-2 country code or the string from_token. """ return self._get( "search", q=q, limit=limit, offset=offset, type=type, market=market ) def search_markets(self, q, limit=10, offset=0, type="track", markets=None, total=None): """ (experimental) Searches multiple markets for an item Parameters: - q - the search query (see how to write a query in the official documentation https://developer.spotify.com/documentation/web-api/reference/search/) # noqa - limit - the number of items to return (min = 1, default = 10, max = 50). If a search is to be done on multiple markets, then this limit is applied to each market. (e.g. search US, CA, MX each with a limit of 10). If multiple types are specified, this applies to each type. - offset - the index of the first item to return - type - the types of items to return. One or more of 'artist', 'album', 'track', 'playlist', 'show', or 'episode'. If multiple types are desired, pass in a comma separated string. - markets - A list of ISO 3166-1 alpha-2 country codes. Search all country markets by default. - total - the total number of results to return across multiple markets and types. """ warnings.warn( "Searching multiple markets is an experimental feature. " "Please be aware that this method's inputs and outputs can change in the future.", UserWarning, ) if not markets: markets = self.country_codes if not (isinstance(markets, list) or isinstance(markets, tuple)): markets = [] warnings.warn( "Searching multiple markets is poorly performing.", UserWarning, ) return self._search_multiple_markets(q, limit, offset, type, markets, total) def user(self, user): """ Gets basic profile information about a Spotify User Parameters: - user - the id of the usr """ return self._get("users/" + user) def current_user_playlists(self, limit=50, offset=0): """ Get current user playlists without required getting his profile Parameters: - limit - the number of items to return - offset - the index of the first item to return """ return self._get("me/playlists", limit=limit, offset=offset) def playlist(self, playlist_id, fields=None, market=None, additional_types=("track",)): """ Gets playlist by id. Parameters: - playlist - the id of the playlist - fields - which fields to return - market - An ISO 3166-1 alpha-2 country code or the string from_token. - additional_types - list of item types to return. valid types are: track and episode """ plid = self._get_id("playlist", playlist_id) return self._get( f"playlists/{plid}", fields=fields, market=market, additional_types=",".join(additional_types), ) def playlist_tracks( self, playlist_id, fields=None, limit=100, offset=0, market=None, additional_types=("track",) ): """ Get full details of the tracks of a playlist. Parameters: - playlist_id - the playlist ID, URI or URL - fields - which fields to return - limit - the maximum number of tracks to return - offset - the index of the first track to return - market - an ISO 3166-1 alpha-2 country code. - additional_types - list of item types to return. valid types are: track and episode """ warnings.warn( "You should use `playlist_items(playlist_id, ...," "additional_types=('track',))` instead", DeprecationWarning, ) return self.playlist_items(playlist_id, fields, limit, offset, market, additional_types) def playlist_items( self, playlist_id, fields=None, limit=100, offset=0, market=None, additional_types=("track", "episode") ): """ Get full details of the tracks and episodes of a playlist. Parameters: - playlist_id - the playlist ID, URI or URL - fields - which fields to return - limit - the maximum number of tracks to return - offset - the index of the first track to return - market - an ISO 3166-1 alpha-2 country code. - additional_types - list of item types to return. valid types are: track and episode """ plid = self._get_id("playlist", playlist_id) return self._get( f"playlists/{plid}/tracks", limit=limit, offset=offset, fields=fields, market=market, additional_types=",".join(additional_types) ) def playlist_cover_image(self, playlist_id): """ Get cover image of a playlist. Parameters: - playlist_id - the playlist ID, URI or URL """ plid = self._get_id("playlist", playlist_id) return self._get(f"playlists/{plid}/images") def playlist_upload_cover_image(self, playlist_id, image_b64): """ Replace the image used to represent a specific playlist Parameters: - playlist_id - the id of the playlist - image_b64 - image data as a Base64 encoded JPEG image string (maximum payload size is 256 KB) """ plid = self._get_id("playlist", playlist_id) return self._put( f"playlists/{plid}/images", payload=image_b64, content_type="image/jpeg", ) def user_playlist(self, user, playlist_id=None, fields=None, market=None): warnings.warn( "You should use `playlist(playlist_id)` instead", DeprecationWarning, ) """ Gets a single playlist of a user Parameters: - user - the id of the user - playlist_id - the id of the playlist - fields - which fields to return """ if playlist_id is None: return self._get(f"users/{user}/starred") return self.playlist(playlist_id, fields=fields, market=market) def user_playlist_tracks( self, user=None, playlist_id=None, fields=None, limit=100, offset=0, market=None, ): warnings.warn( "You should use `playlist_tracks(playlist_id)` instead", DeprecationWarning, ) """ Get full details of the tracks of a playlist owned by a user. Parameters: - user - the id of the user - playlist_id - the id of the playlist - fields - which fields to return - limit - the maximum number of tracks to return - offset - the index of the first track to return - market - an ISO 3166-1 alpha-2 country code. """ return self.playlist_tracks( playlist_id, limit=limit, offset=offset, fields=fields, market=market, ) def user_playlists(self, user, limit=50, offset=0): """ Gets playlists of a user Parameters: - user - the id of the usr - limit - the number of items to return - offset - the index of the first item to return """ return self._get( f"users/{user}/playlists", limit=limit, offset=offset ) def user_playlist_create(self, user, name, public=True, collaborative=False, description=""): """ Creates a playlist for a user Parameters: - user - the id of the user - name - the name of the playlist - public - is the created playlist public - collaborative - is the created playlist collaborative - description - the description of the playlist """ data = { "name": name, "public": public, "collaborative": collaborative, "description": description } return self._post(f"users/{user}/playlists", payload=data) def user_playlist_change_details( self, user, playlist_id, name=None, public=None, collaborative=None, description=None, ): warnings.warn( "You should use `playlist_change_details(playlist_id, ...)` instead", DeprecationWarning, ) """ Changes a playlist's name and/or public/private state Parameters: - user - the id of the user - playlist_id - the id of the playlist - name - optional name of the playlist - public - optional is the playlist public - collaborative - optional is the playlist collaborative - description - optional description of the playlist """ return self.playlist_change_details(playlist_id, name, public, collaborative, description) def user_playlist_unfollow(self, user, playlist_id): """ Unfollows (deletes) a playlist for a user Parameters: - user - the id of the user - name - the name of the playlist """ warnings.warn( "You should use `current_user_unfollow_playlist(playlist_id)` instead", DeprecationWarning, ) return self.current_user_unfollow_playlist(playlist_id) def user_playlist_add_tracks( self, user, playlist_id, tracks, position=None ): warnings.warn( "You should use `playlist_add_items(playlist_id, tracks)` instead", DeprecationWarning, ) """ Adds tracks to a playlist Parameters: - user - the id of the user - playlist_id - the id of the playlist - tracks - a list of track URIs, URLs or IDs - position - the position to add the tracks """ tracks = [self._get_uri("track", tid) for tid in tracks] return self.playlist_add_items(playlist_id, tracks, position) def user_playlist_add_episodes( self, user, playlist_id, episodes, position=None ): warnings.warn( "You should use `playlist_add_items(playlist_id, episodes)` instead", DeprecationWarning, ) """ Adds episodes to a playlist Parameters: - user - the id of the user - playlist_id - the id of the playlist - episodes - a list of track URIs, URLs or IDs - position - the position to add the episodes """ episodes = [self._get_uri("episode", tid) for tid in episodes] return self.playlist_add_items(playlist_id, episodes, position) def user_playlist_replace_tracks(self, user, playlist_id, tracks): """ Replace all tracks in a playlist for a user Parameters: - user - the id of the user - playlist_id - the id of the playlist - tracks - the list of track ids to add to the playlist """ warnings.warn( "You should use `playlist_replace_items(playlist_id, tracks)` instead", DeprecationWarning, ) return self.playlist_replace_items(playlist_id, tracks) def user_playlist_reorder_tracks( self, user, playlist_id, range_start, insert_before, range_length=1, snapshot_id=None, ): """ Reorder tracks in a playlist from a user Parameters: - user - the id of the user - playlist_id - the id of the playlist - range_start - the position of the first track to be reordered - range_length - optional the number of tracks to be reordered (default: 1) - insert_before - the position where the tracks should be inserted - snapshot_id - optional playlist's snapshot ID """ warnings.warn( "You should use `playlist_reorder_items(playlist_id, ...)` instead", DeprecationWarning, ) return self.playlist_reorder_items(playlist_id, range_start, insert_before, range_length, snapshot_id) def user_playlist_remove_all_occurrences_of_tracks( self, user, playlist_id, tracks, snapshot_id=None ): """ Removes all occurrences of the given tracks from the given playlist Parameters: - user - the id of the user - playlist_id - the id of the playlist - tracks - the list of track ids to remove from the playlist - snapshot_id - optional id of the playlist snapshot """ warnings.warn( "You should use `playlist_remove_all_occurrences_of_items" "(playlist_id, tracks)` instead", DeprecationWarning, ) return self.playlist_remove_all_occurrences_of_items(playlist_id, tracks, snapshot_id) def user_playlist_remove_specific_occurrences_of_tracks( self, user, playlist_id, tracks, snapshot_id=None ): """ Removes all occurrences of the given tracks from the given playlist Parameters: - user - the id of the user - playlist_id - the id of the playlist - tracks - an array of objects containing Spotify URIs of the tracks to remove with their current positions in the playlist. For example: [ { "uri":"4iV5W9uYEdYUVa79Axb7Rh", "positions":[2] }, { "uri":"1301WleyT98MSxVHPZCA6M", "positions":[7] } ] - snapshot_id - optional id of the playlist snapshot """ warnings.warn( "You should use `playlist_remove_specific_occurrences_of_items" "(playlist_id, tracks)` instead", DeprecationWarning, ) plid = self._get_id("playlist", playlist_id) ftracks = [] for tr in tracks: ftracks.append( { "uri": self._get_uri("track", tr["uri"]), "positions": tr["positions"], } ) payload = {"tracks": ftracks} if snapshot_id: payload["snapshot_id"] = snapshot_id return self._delete( f"users/{user}/playlists/{plid}/tracks", payload=payload ) def user_playlist_follow_playlist(self, playlist_owner_id, playlist_id): """ Add the current authenticated user as a follower of a playlist. Parameters: - playlist_owner_id - the user id of the playlist owner - playlist_id - the id of the playlist """ warnings.warn( "You should use `current_user_follow_playlist(playlist_id)` instead", DeprecationWarning, ) return self.current_user_follow_playlist(playlist_id) def user_playlist_is_following( self, playlist_owner_id, playlist_id, user_ids ): """ Check to see if the given users are following the given playlist Parameters: - playlist_owner_id - the user id of the playlist owner - playlist_id - the id of the playlist - user_ids - the ids of the users that you want to check to see if they follow the playlist. Maximum: 5 ids. """ warnings.warn( "You should use `playlist_is_following(playlist_id, user_ids)` instead", DeprecationWarning, ) return self.playlist_is_following(playlist_id, user_ids) def playlist_change_details( self, playlist_id, name=None, public=None, collaborative=None, description=None, ): """ Changes a playlist's name and/or public/private state, collaborative state, and/or description Parameters: - playlist_id - the id of the playlist - name - optional name of the playlist - public - optional is the playlist public - collaborative - optional is the playlist collaborative - description - optional description of the playlist """ data = {} if isinstance(name, str): data["name"] = name if isinstance(public, bool): data["public"] = public if isinstance(collaborative, bool): data["collaborative"] = collaborative if isinstance(description, str): data["description"] = description return self._put( f"playlists/{self._get_id('playlist', playlist_id)}", payload=data ) def current_user_unfollow_playlist(self, playlist_id): """ Unfollows (deletes) a playlist for the current authenticated user Parameters: - name - the name of the playlist """ return self._delete( f"playlists/{playlist_id}/followers" ) def playlist_add_items( self, playlist_id, items, position=None ): """ Adds tracks/episodes to a playlist Parameters: - playlist_id - the id of the playlist - items - a list of track/episode URIs or URLs - position - the position to add the tracks """ plid = self._get_id("playlist", playlist_id) ftracks = [self._get_uri("track", tid) for tid in items] return self._post( f"playlists/{plid}/tracks", payload=ftracks, position=position, ) def playlist_replace_items(self, playlist_id, items): """ Replace all tracks/episodes in a playlist Parameters: - playlist_id - the id of the playlist - items - list of track/episode ids to comprise playlist """ plid = self._get_id("playlist", playlist_id) ftracks = [self._get_uri("track", tid) for tid in items] payload = {"uris": ftracks} return self._put( f"playlists/{plid}/tracks", payload=payload ) def playlist_reorder_items( self, playlist_id, range_start, insert_before, range_length=1, snapshot_id=None, ): """ Reorder tracks in a playlist Parameters: - playlist_id - the id of the playlist - range_start - the position of the first track to be reordered - range_length - optional the number of tracks to be reordered (default: 1) - insert_before - the position where the tracks should be inserted - snapshot_id - optional playlist's snapshot ID """ plid = self._get_id("playlist", playlist_id) payload = { "range_start": range_start, "range_length": range_length, "insert_before": insert_before, } if snapshot_id: payload["snapshot_id"] = snapshot_id return self._put( f"playlists/{plid}/tracks", payload=payload ) def playlist_remove_all_occurrences_of_items( self, playlist_id, items, snapshot_id=None ): """ Removes all occurrences of the given tracks/episodes from the given playlist Parameters: - playlist_id - the id of the playlist - items - list of track/episode ids to remove from the playlist - snapshot_id - optional id of the playlist snapshot """ plid = self._get_id("playlist", playlist_id) ftracks = [self._get_uri("track", tid) for tid in items] payload = {"tracks": [{"uri": track} for track in ftracks]} if snapshot_id: payload["snapshot_id"] = snapshot_id return self._delete( f"playlists/{plid}/tracks", payload=payload ) def playlist_remove_specific_occurrences_of_items( self, playlist_id, items, snapshot_id=None ): """ Removes all occurrences of the given tracks from the given playlist Parameters: - playlist_id - the id of the playlist - items - an array of objects containing Spotify URIs of the tracks/episodes to remove with their current positions in the playlist. For example: [ { "uri":"4iV5W9uYEdYUVa79Axb7Rh", "positions":[2] }, { "uri":"1301WleyT98MSxVHPZCA6M", "positions":[7] } ] - snapshot_id - optional id of the playlist snapshot """ plid = self._get_id("playlist", playlist_id) ftracks = [] for tr in items: ftracks.append( { "uri": self._get_uri("track", tr["uri"]), "positions": tr["positions"], } ) payload = {"tracks": ftracks} if snapshot_id: payload["snapshot_id"] = snapshot_id return self._delete( f"playlists/{plid}/tracks", payload=payload ) def current_user_follow_playlist(self, playlist_id, public=True): """ Add the current authenticated user as a follower of a playlist. Parameters: - playlist_id - the id of the playlist """ return self._put( f"playlists/{playlist_id}/followers", payload={"public": public} ) def playlist_is_following( self, playlist_id, user_ids ): """ Check to see if the given users are following the given playlist Parameters: - playlist_id - the id of the playlist - user_ids - the ids of the users that you want to check to see if they follow the playlist. Maximum: 5 ids. """ endpoint = "playlists/{}/followers/contains?ids={}" return self._get( endpoint.format(playlist_id, ",".join(user_ids)) ) def me(self): """ Get detailed profile information about the current user. An alias for the 'current_user' method. """ return self._get("me/") def current_user(self): """ Get detailed profile information about the current user. An alias for the 'me' method. """ return self.me() def current_user_playing_track(self): """ Get information about the current users currently playing track. """ return self._get("me/player/currently-playing") def current_user_saved_albums(self, limit=20, offset=0, market=None): """ Gets a list of the albums saved in the current authorized user's "Your Music" library Parameters: - limit - the number of albums to return (MAX_LIMIT=50) - offset - the index of the first album to return - market - an ISO 3166-1 alpha-2 country code. """ return self._get("me/albums", limit=limit, offset=offset, market=market) def current_user_saved_albums_add(self, albums=[]): """ Add one or more albums to the current user's "Your Music" library. Parameters: - albums - a list of album URIs, URLs or IDs """ alist = [self._get_id("album", a) for a in albums] return self._put("me/albums?ids=" + ",".join(alist)) def current_user_saved_albums_delete(self, albums=[]): """ Remove one or more albums from the current user's "Your Music" library. Parameters: - albums - a list of album URIs, URLs or IDs """ alist = [self._get_id("album", a) for a in albums] return self._delete("me/albums/?ids=" + ",".join(alist)) def current_user_saved_albums_contains(self, albums=[]): """ Check if one or more albums is already saved in the current Spotify user’s ā€œYour Musicā€ library. Parameters: - albums - a list of album URIs, URLs or IDs """ alist = [self._get_id("album", a) for a in albums] return self._get("me/albums/contains?ids=" + ",".join(alist)) def current_user_saved_tracks(self, limit=20, offset=0, market=None): """ Gets a list of the tracks saved in the current authorized user's "Your Music" library Parameters: - limit - the number of tracks to return - offset - the index of the first track to return - market - an ISO 3166-1 alpha-2 country code """ return self._get("me/tracks", limit=limit, offset=offset, market=market) def current_user_saved_tracks_add(self, tracks=None): """ Add one or more tracks to the current user's "Your Music" library. Parameters: - tracks - a list of track URIs, URLs or IDs """ tlist = [] if tracks is not None: tlist = [self._get_id("track", t) for t in tracks] return self._put("me/tracks/?ids=" + ",".join(tlist)) def current_user_saved_tracks_delete(self, tracks=None): """ Remove one or more tracks from the current user's "Your Music" library. Parameters: - tracks - a list of track URIs, URLs or IDs """ tlist = [] if tracks is not None: tlist = [self._get_id("track", t) for t in tracks] return self._delete("me/tracks/?ids=" + ",".join(tlist)) def current_user_saved_tracks_contains(self, tracks=None): """ Check if one or more tracks is already saved in the current Spotify user’s ā€œYour Musicā€ library. Parameters: - tracks - a list of track URIs, URLs or IDs """ tlist = [] if tracks is not None: tlist = [self._get_id("track", t) for t in tracks] return self._get("me/tracks/contains?ids=" + ",".join(tlist)) def current_user_saved_episodes(self, limit=20, offset=0, market=None): """ Gets a list of the episodes saved in the current authorized user's "Your Music" library Parameters: - limit - the number of episodes to return - offset - the index of the first episode to return - market - an ISO 3166-1 alpha-2 country code """ return self._get("me/episodes", limit=limit, offset=offset, market=market) def current_user_saved_episodes_add(self, episodes=None): """ Add one or more episodes to the current user's "Your Music" library. Parameters: - episodes - a list of episode URIs, URLs or IDs """ elist = [] if episodes is not None: elist = [self._get_id("episode", e) for e in episodes] return self._put("me/episodes/?ids=" + ",".join(elist)) def current_user_saved_episodes_delete(self, episodes=None): """ Remove one or more episodes from the current user's "Your Music" library. Parameters: - episodes - a list of episode URIs, URLs or IDs """ elist = [] if episodes is not None: elist = [self._get_id("episode", e) for e in episodes] return self._delete("me/episodes/?ids=" + ",".join(elist)) def current_user_saved_episodes_contains(self, episodes=None): """ Check if one or more episodes is already saved in the current Spotify user’s ā€œYour Musicā€ library. Parameters: - episodes - a list of episode URIs, URLs or IDs """ elist = [] if episodes is not None: elist = [self._get_id("episode", e) for e in episodes] return self._get("me/episodes/contains?ids=" + ",".join(elist)) def current_user_saved_shows(self, limit=20, offset=0, market=None): """ Gets a list of the shows saved in the current authorized user's "Your Music" library Parameters: - limit - the number of shows to return - offset - the index of the first show to return - market - an ISO 3166-1 alpha-2 country code """ return self._get("me/shows", limit=limit, offset=offset, market=market) def current_user_saved_shows_add(self, shows=[]): """ Add one or more albums to the current user's "Your Music" library. Parameters: - shows - a list of show URIs, URLs or IDs """ slist = [self._get_id("show", s) for s in shows] return self._put("me/shows?ids=" + ",".join(slist)) def current_user_saved_shows_delete(self, shows=[]): """ Remove one or more shows from the current user's "Your Music" library. Parameters: - shows - a list of show URIs, URLs or IDs """ slist = [self._get_id("show", s) for s in shows] return self._delete("me/shows/?ids=" + ",".join(slist)) def current_user_saved_shows_contains(self, shows=[]): """ Check if one or more shows is already saved in the current Spotify user’s ā€œYour Musicā€ library. Parameters: - shows - a list of show URIs, URLs or IDs """ slist = [self._get_id("show", s) for s in shows] return self._get("me/shows/contains?ids=" + ",".join(slist)) def current_user_followed_artists(self, limit=20, after=None): """ Gets a list of the artists followed by the current authorized user Parameters: - limit - the number of artists to return - after - the last artist ID retrieved from the previous request """ return self._get( "me/following", type="artist", limit=limit, after=after ) def current_user_following_artists(self, ids=None): """ Check if the current user is following certain artists Returns list of booleans respective to ids Parameters: - ids - a list of artist URIs, URLs or IDs """ idlist = [] if ids is not None: idlist = [self._get_id("artist", i) for i in ids] return self._get( "me/following/contains", ids=",".join(idlist), type="artist" ) def current_user_following_users(self, ids=None): """ Check if the current user is following certain users Returns list of booleans respective to ids Parameters: - ids - a list of user URIs, URLs or IDs """ idlist = [] if ids is not None: idlist = [self._get_id("user", i) for i in ids] return self._get( "me/following/contains", ids=",".join(idlist), type="user" ) def current_user_top_artists( self, limit=20, offset=0, time_range="medium_term" ): """ Get the current user's top artists Parameters: - limit - the number of entities to return - offset - the index of the first entity to return - time_range - Over what time frame are the affinities computed Valid-values: short_term, medium_term, long_term """ return self._get( "me/top/artists", time_range=time_range, limit=limit, offset=offset ) def current_user_top_tracks( self, limit=20, offset=0, time_range="medium_term" ): """ Get the current user's top tracks Parameters: - limit - the number of entities to return - offset - the index of the first entity to return - time_range - Over what time frame are the affinities computed Valid-values: short_term, medium_term, long_term """ return self._get( "me/top/tracks", time_range=time_range, limit=limit, offset=offset ) def current_user_recently_played(self, limit=50, after=None, before=None): """ Get the current user's recently played tracks Parameters: - limit - the number of entities to return - after - unix timestamp in milliseconds. Returns all items after (but not including) this cursor position. Cannot be used if before is specified. - before - unix timestamp in milliseconds. Returns all items before (but not including) this cursor position. Cannot be used if after is specified """ return self._get( "me/player/recently-played", limit=limit, after=after, before=before, ) def user_follow_artists(self, ids=[]): """ Follow one or more artists Parameters: - ids - a list of artist IDs """ return self._put("me/following?type=artist&ids=" + ",".join(ids)) def user_follow_users(self, ids=[]): """ Follow one or more users Parameters: - ids - a list of user IDs """ return self._put("me/following?type=user&ids=" + ",".join(ids)) def user_unfollow_artists(self, ids=[]): """ Unfollow one or more artists Parameters: - ids - a list of artist IDs """ return self._delete("me/following?type=artist&ids=" + ",".join(ids)) def user_unfollow_users(self, ids=[]): """ Unfollow one or more users Parameters: - ids - a list of user IDs """ return self._delete("me/following?type=user&ids=" + ",".join(ids)) def featured_playlists( self, locale=None, country=None, timestamp=None, limit=20, offset=0 ): """ Get a list of Spotify featured playlists Parameters: - locale - The desired language, consisting of a lowercase ISO 639-1 alpha-2 language code and an uppercase ISO 3166-1 alpha-2 country code, joined by an underscore. - country - An ISO 3166-1 alpha-2 country code. - timestamp - A timestamp in ISO 8601 format: yyyy-MM-ddTHH:mm:ss. Use this parameter to specify the user's local time to get results tailored for that specific date and time in the day - limit - The maximum number of items to return. Default: 20. Minimum: 1. Maximum: 50 - offset - The index of the first item to return. Default: 0 (the first object). Use with limit to get the next set of items. """ return self._get( "browse/featured-playlists", locale=locale, country=country, timestamp=timestamp, limit=limit, offset=offset, ) def new_releases(self, country=None, limit=20, offset=0): """ Get a list of new album releases featured in Spotify Parameters: - country - An ISO 3166-1 alpha-2 country code. - limit - The maximum number of items to return. Default: 20. Minimum: 1. Maximum: 50 - offset - The index of the first item to return. Default: 0 (the first object). Use with limit to get the next set of items. """ return self._get( "browse/new-releases", country=country, limit=limit, offset=offset ) def category(self, category_id, country=None, locale=None): """ Get info about a category Parameters: - category_id - The Spotify category ID for the category. - country - An ISO 3166-1 alpha-2 country code. - locale - The desired language, consisting of an ISO 639-1 alpha-2 language code and an ISO 3166-1 alpha-2 country code, joined by an underscore. """ return self._get( "browse/categories/" + category_id, country=country, locale=locale, ) def categories(self, country=None, locale=None, limit=20, offset=0): """ Get a list of categories Parameters: - country - An ISO 3166-1 alpha-2 country code. - locale - The desired language, consisting of an ISO 639-1 alpha-2 language code and an ISO 3166-1 alpha-2 country code, joined by an underscore. - limit - The maximum number of items to return. Default: 20. Minimum: 1. Maximum: 50 - offset - The index of the first item to return. Default: 0 (the first object). Use with limit to get the next set of items. """ return self._get( "browse/categories", country=country, locale=locale, limit=limit, offset=offset, ) def category_playlists( self, category_id=None, country=None, limit=20, offset=0 ): """ Get a list of playlists for a specific Spotify category Parameters: - category_id - The Spotify category ID for the category. - country - An ISO 3166-1 alpha-2 country code. - limit - The maximum number of items to return. Default: 20. Minimum: 1. Maximum: 50 - offset - The index of the first item to return. Default: 0 (the first object). Use with limit to get the next set of items. """ return self._get( "browse/categories/" + category_id + "/playlists", country=country, limit=limit, offset=offset, ) def recommendations( self, seed_artists=None, seed_genres=None, seed_tracks=None, limit=20, country=None, **kwargs ): """ Get a list of recommended tracks for one to five seeds. (at least one of `seed_artists`, `seed_tracks` and `seed_genres` are needed) Parameters: - seed_artists - a list of artist IDs, URIs or URLs - seed_tracks - a list of track IDs, URIs or URLs - seed_genres - a list of genre names. Available genres for recommendations can be found by calling recommendation_genre_seeds - country - An ISO 3166-1 alpha-2 country code. If provided, all results will be playable in this country. - limit - The maximum number of items to return. Default: 20. Minimum: 1. Maximum: 100 - min/max/target_ - For the tuneable track attributes listed in the documentation, these values provide filters and targeting on results. """ params = dict(limit=limit) if seed_artists: params["seed_artists"] = ",".join( [self._get_id("artist", a) for a in seed_artists] ) if seed_genres: params["seed_genres"] = ",".join(seed_genres) if seed_tracks: params["seed_tracks"] = ",".join( [self._get_id("track", t) for t in seed_tracks] ) if country: params["market"] = country for attribute in [ "acousticness", "danceability", "duration_ms", "energy", "instrumentalness", "key", "liveness", "loudness", "mode", "popularity", "speechiness", "tempo", "time_signature", "valence", ]: for prefix in ["min_", "max_", "target_"]: param = prefix + attribute if param in kwargs: params[param] = kwargs[param] return self._get("recommendations", **params) def recommendation_genre_seeds(self): """ Get a list of genres available for the recommendations function. """ return self._get("recommendations/available-genre-seeds") def audio_analysis(self, track_id): """ Get audio analysis for a track based upon its Spotify ID Parameters: - track_id - a track URI, URL or ID """ trid = self._get_id("track", track_id) return self._get("audio-analysis/" + trid) def audio_features(self, tracks=[]): """ Get audio features for one or multiple tracks based upon their Spotify IDs Parameters: - tracks - a list of track URIs, URLs or IDs, maximum: 100 ids """ if isinstance(tracks, str): trackid = self._get_id("track", tracks) results = self._get("audio-features/?ids=" + trackid) else: tlist = [self._get_id("track", t) for t in tracks] results = self._get("audio-features/?ids=" + ",".join(tlist)) # the response has changed, look for the new style first, and if # it's not there, fallback on the old style if "audio_features" in results: return results["audio_features"] else: return results def devices(self): """ Get a list of user's available devices. """ return self._get("me/player/devices") def current_playback(self, market=None, additional_types=None): """ Get information about user's current playback. Parameters: - market - an ISO 3166-1 alpha-2 country code. - additional_types - `episode` to get podcast track information """ return self._get("me/player", market=market, additional_types=additional_types) def currently_playing(self, market=None, additional_types=None): """ Get user's currently playing track. Parameters: - market - an ISO 3166-1 alpha-2 country code. - additional_types - `episode` to get podcast track information """ return self._get("me/player/currently-playing", market=market, additional_types=additional_types) def transfer_playback(self, device_id, force_play=True): """ Transfer playback to another device. Note that the API accepts a list of device ids, but only actually supports one. Parameters: - device_id - transfer playback to this device - force_play - true: after transfer, play. false: keep current state. """ data = {"device_ids": [device_id], "play": force_play} return self._put("me/player", payload=data) def start_playback( self, device_id=None, context_uri=None, uris=None, offset=None, position_ms=None ): """ Start or resume user's playback. Provide a `context_uri` to start playback of an album, artist, or playlist. Provide a `uris` list to start playback of one or more tracks. Provide `offset` as {"position": } or {"uri": ""} to start playback at a particular offset. Parameters: - device_id - device target for playback - context_uri - spotify context uri to play - uris - spotify track uris - offset - offset into context by index or track - position_ms - (optional) indicates from what position to start playback. Must be a positive number. Passing in a position that is greater than the length of the track will cause the player to start playing the next song. """ if context_uri is not None and uris is not None: logger.warning("Specify either context uri or uris, not both") return if uris is not None and not isinstance(uris, list): logger.warning("URIs must be a list") return data = {} if context_uri is not None: data["context_uri"] = context_uri if uris is not None: data["uris"] = uris if offset is not None: data["offset"] = offset if position_ms is not None: data["position_ms"] = position_ms return self._put( self._append_device_id("me/player/play", device_id), payload=data ) def pause_playback(self, device_id=None): """ Pause user's playback. Parameters: - device_id - device target for playback """ return self._put(self._append_device_id("me/player/pause", device_id)) def next_track(self, device_id=None): """ Skip user's playback to next track. Parameters: - device_id - device target for playback """ return self._post(self._append_device_id("me/player/next", device_id)) def previous_track(self, device_id=None): """ Skip user's playback to previous track. Parameters: - device_id - device target for playback """ return self._post( self._append_device_id("me/player/previous", device_id) ) def seek_track(self, position_ms, device_id=None): """ Seek to position in current track. Parameters: - position_ms - position in milliseconds to seek to - device_id - device target for playback """ if not isinstance(position_ms, int): logger.warning("Position_ms must be an integer") return return self._put( self._append_device_id( f"me/player/seek?position_ms={position_ms}", device_id ) ) def repeat(self, state, device_id=None): """ Set repeat mode for playback. Parameters: - state - `track`, `context`, or `off` - device_id - device target for playback """ if state not in ["track", "context", "off"]: logger.warning("Invalid state") return self._put( self._append_device_id( f"me/player/repeat?state={state}", device_id ) ) def volume(self, volume_percent, device_id=None): """ Set playback volume. Parameters: - volume_percent - volume between 0 and 100 - device_id - device target for playback """ if not isinstance(volume_percent, int): logger.warning("Volume must be an integer") return if volume_percent < 0 or volume_percent > 100: logger.warning("Volume must be between 0 and 100, inclusive") return self._put( self._append_device_id( f"me/player/volume?volume_percent={volume_percent}", device_id, ) ) def shuffle(self, state, device_id=None): """ Toggle playback shuffling. Parameters: - state - true or false - device_id - device target for playback """ if not isinstance(state, bool): logger.warning("state must be a boolean") return state = str(state).lower() self._put( self._append_device_id( f"me/player/shuffle?state={state}", device_id ) ) def queue(self): """ Gets the current user's queue """ return self._get("me/player/queue") def add_to_queue(self, uri, device_id=None): """ Adds a song to the end of a user's queue If device A is currently playing music, and you try to add to the queue and pass in the id for device B, you will get a 'Player command failed: Restriction violated' error I therefore recommend leaving device_id as None so that the active device is targeted :param uri: song uri, id, or url :param device_id: the id of a Spotify device. If None, then the active device is used. """ uri = self._get_uri("track", uri) endpoint = f"me/player/queue?uri={uri}" if device_id is not None: endpoint += f"&device_id={device_id}" return self._post(endpoint) def available_markets(self): """ Get the list of markets where Spotify is available. Returns a list of the countries in which Spotify is available, identified by their ISO 3166-1 alpha-2 country code with additional country codes for special territories. """ return self._get("markets") def _append_device_id(self, path, device_id): """ Append device ID to API path. Parameters: - device_id - device id to append """ if device_id: if "?" in path: path += f"&device_id={device_id}" else: path += f"?device_id={device_id}" return path def _get_id(self, type, id): uri_match = re.search(Spotify._regex_spotify_uri, id) if uri_match is not None: uri_match_groups = uri_match.groupdict() if uri_match_groups['type'] != type: # TODO change to a ValueError in v3 raise SpotifyException(400, -1, "Unexpected Spotify URI type.") return uri_match_groups['id'] url_match = re.search(Spotify._regex_spotify_url, id) if url_match is not None: url_match_groups = url_match.groupdict() if url_match_groups['type'] != type: raise SpotifyException(400, -1, "Unexpected Spotify URL type.") # TODO change to a ValueError in v3 return url_match_groups['id'] # Raw identifiers might be passed, ensure they are also base-62 if re.search(Spotify._regex_base62, id) is not None: return id # TODO change to a ValueError in v3 raise SpotifyException(400, -1, "Unsupported URL / URI.") def _get_uri(self, type, id): if self._is_uri(id): return id else: return "spotify:" + type + ":" + self._get_id(type, id) def _is_uri(self, uri): return re.search(Spotify._regex_spotify_uri, uri) is not None def _search_multiple_markets(self, q, limit, offset, type, markets, total): if total and limit > total: limit = total warnings.warn( "limit was auto-adjusted to equal {} as it must not be higher than total".format( total), UserWarning, ) results = defaultdict(dict) item_types = [item_type + "s" for item_type in type.split(",")] count = 0 for country in markets: result = self._get( "search", q=q, limit=limit, offset=offset, type=type, market=country ) for item_type in item_types: results[country][item_type] = result[item_type] # Truncate the items list to the current limit if len(results[country][item_type]['items']) > limit: results[country][item_type]['items'] = \ results[country][item_type]['items'][:limit] count += len(results[country][item_type]['items']) if total and limit > total - count: # when approaching `total` results, adjust `limit` to not request more # items than needed limit = total - count if total and count >= total: return results return results def get_audiobook(self, id, market=None): """ Get Spotify catalog information for a single audiobook identified by its unique Spotify ID. Parameters: - id - the Spotify ID for the audiobook - market - an ISO 3166-1 alpha-2 country code. """ audiobook_id = self._get_id("audiobook", id) endpoint = f"audiobooks/{audiobook_id}" if market: endpoint += f'?market={market}' return self._get(endpoint) def get_audiobooks(self, ids, market=None): """ Get Spotify catalog information for multiple audiobooks based on their Spotify IDs. Parameters: - ids - a list of Spotify IDs for the audiobooks - market - an ISO 3166-1 alpha-2 country code. """ audiobook_ids = [self._get_id("audiobook", id) for id in ids] endpoint = f"audiobooks?ids={','.join(audiobook_ids)}" if market: endpoint += f'&market={market}' return self._get(endpoint) def get_audiobook_chapters(self, id, market=None, limit=20, offset=0): """ Get Spotify catalog information about an audiobook’s chapters. Parameters: - id - the Spotify ID for the audiobook - market - an ISO 3166-1 alpha-2 country code. - limit - the maximum number of items to return - offset - the index of the first item to return """ audiobook_id = self._get_id("audiobook", id) endpoint = f"audiobooks/{audiobook_id}/chapters?limit={limit}&offset={offset}" if market: endpoint += f'&market={market}' return self._get(endpoint) spotipy-dev-spotipy-8a40e03/spotipy/exceptions.py000066400000000000000000000010641462616510500223010ustar00rootroot00000000000000class SpotifyException(Exception): def __init__(self, http_status, code, msg, reason=None, headers=None): self.http_status = http_status self.code = code self.msg = msg self.reason = reason # `headers` is used to support `Retry-After` in the event of a # 429 status code. if headers is None: headers = {} self.headers = headers def __str__(self): return 'http status: {}, code:{} - {}, reason: {}'.format( self.http_status, self.code, self.msg, self.reason) spotipy-dev-spotipy-8a40e03/spotipy/oauth2.py000066400000000000000000001472241462616510500213330ustar00rootroot00000000000000__all__ = [ "SpotifyClientCredentials", "SpotifyOAuth", "SpotifyOauthError", "SpotifyStateError", "SpotifyImplicitGrant", "SpotifyPKCE" ] import base64 import logging import os import time import warnings import webbrowser import requests import urllib.parse as urllibparse from http.server import BaseHTTPRequestHandler, HTTPServer from urllib.parse import parse_qsl, urlparse from spotipy.cache_handler import CacheFileHandler, CacheHandler from spotipy.util import CLIENT_CREDS_ENV_VARS, get_host_port, normalize_scope logger = logging.getLogger(__name__) class SpotifyOauthError(Exception): """ Error during Auth Code or Implicit Grant flow """ def __init__(self, message, error=None, error_description=None, *args, **kwargs): self.error = error self.error_description = error_description self.__dict__.update(kwargs) super().__init__(message, *args, **kwargs) class SpotifyStateError(SpotifyOauthError): """ The state sent and state received were different """ def __init__(self, local_state=None, remote_state=None, message=None, error=None, error_description=None, *args, **kwargs): if not message: message = ("Expected " + local_state + " but received " + remote_state) super(SpotifyOauthError, self).__init__(message, error, error_description, *args, **kwargs) def _make_authorization_headers(client_id, client_secret): auth_header = base64.b64encode( str(client_id + ":" + client_secret).encode("ascii") ) return {"Authorization": f"Basic {auth_header.decode('ascii')}"} def _ensure_value(value, env_key): env_val = CLIENT_CREDS_ENV_VARS[env_key] _val = value or os.getenv(env_val) if _val is None: msg = f"No {env_key}. Pass it or set a {env_val} environment variable." raise SpotifyOauthError(msg) return _val class SpotifyAuthBase: def __init__(self, requests_session): if isinstance(requests_session, requests.Session): self._session = requests_session else: if requests_session: # Build a new session. self._session = requests.Session() else: # Use the Requests API module as a "session". from requests import api self._session = api def _normalize_scope(self, scope): return normalize_scope(scope) @property def client_id(self): return self._client_id @client_id.setter def client_id(self, val): self._client_id = _ensure_value(val, "client_id") @property def client_secret(self): return self._client_secret @client_secret.setter def client_secret(self, val): self._client_secret = _ensure_value(val, "client_secret") @property def redirect_uri(self): return self._redirect_uri @redirect_uri.setter def redirect_uri(self, val): self._redirect_uri = _ensure_value(val, "redirect_uri") @staticmethod def _get_user_input(prompt): try: return raw_input(prompt) except NameError: return input(prompt) @staticmethod def is_token_expired(token_info): now = int(time.time()) return token_info["expires_at"] - now < 60 @staticmethod def _is_scope_subset(needle_scope, haystack_scope): needle_scope = set(needle_scope.split()) if needle_scope else set() haystack_scope = ( set(haystack_scope.split()) if haystack_scope else set() ) return needle_scope <= haystack_scope def _handle_oauth_error(self, http_error): response = http_error.response try: error_payload = response.json() error = error_payload.get('error') error_description = error_payload.get('error_description') except ValueError: # if the response cannot be decoded into JSON (which raises a ValueError), # then try to decode it into text # if we receive an empty string (which is falsy), then replace it with `None` error = response.text or None error_description = None raise SpotifyOauthError( f'error: {error}, error_description: {error_description}', error=error, error_description=error_description ) def __del__(self): """Make sure the connection (pool) gets closed""" if isinstance(self._session, requests.Session): self._session.close() class SpotifyClientCredentials(SpotifyAuthBase): OAUTH_TOKEN_URL = "https://accounts.spotify.com/api/token" def __init__( self, client_id=None, client_secret=None, proxies=None, requests_session=True, requests_timeout=None, cache_handler=None ): """ Creates a Client Credentials Flow Manager. The Client Credentials flow is used in server-to-server authentication. Only endpoints that do not access user information can be accessed. This means that endpoints that require authorization scopes cannot be accessed. The advantage, however, of this authorization flow is that it does not require any user interaction You can either provide a client_id and client_secret to the constructor or set SPOTIPY_CLIENT_ID and SPOTIPY_CLIENT_SECRET environment variables Parameters: * client_id: Must be supplied or set as environment variable * client_secret: Must be supplied or set as environment variable * proxies: Optional, proxy for the requests library to route through * requests_session: A Requests session * requests_timeout: Optional, tell Requests to stop waiting for a response after a given number of seconds * cache_handler: An instance of the `CacheHandler` class to handle getting and saving cached authorization tokens. Optional, will otherwise use `CacheFileHandler`. (takes precedence over `cache_path` and `username`) """ super().__init__(requests_session) self.client_id = client_id self.client_secret = client_secret self.proxies = proxies self.requests_timeout = requests_timeout if cache_handler: assert issubclass(cache_handler.__class__, CacheHandler), \ "cache_handler must be a subclass of CacheHandler: " + str(type(cache_handler)) \ + " != " + str(CacheHandler) self.cache_handler = cache_handler else: self.cache_handler = CacheFileHandler() def get_access_token(self, as_dict=True, check_cache=True): """ If a valid access token is in memory, returns it Else fetches a new token and returns it Parameters: - as_dict - a boolean indicating if returning the access token as a token_info dictionary, otherwise it will be returned as a string. """ if as_dict: warnings.warn( "You're using 'as_dict = True'." "get_access_token will return the token string directly in future " "versions. Please adjust your code accordingly, or use " "get_cached_token instead.", DeprecationWarning, stacklevel=2, ) if check_cache: token_info = self.cache_handler.get_cached_token() if token_info and not self.is_token_expired(token_info): return token_info if as_dict else token_info["access_token"] token_info = self._request_access_token() token_info = self._add_custom_values_to_token_info(token_info) self.cache_handler.save_token_to_cache(token_info) return token_info if as_dict else token_info["access_token"] def _request_access_token(self): """Gets client credentials access token """ payload = {"grant_type": "client_credentials"} headers = _make_authorization_headers( self.client_id, self.client_secret ) logger.debug( "sending POST request to %s with Headers: %s and Body: %r", self.OAUTH_TOKEN_URL, headers, payload ) try: response = self._session.post( self.OAUTH_TOKEN_URL, data=payload, headers=headers, verify=True, proxies=self.proxies, timeout=self.requests_timeout, ) response.raise_for_status() token_info = response.json() return token_info except requests.exceptions.HTTPError as http_error: self._handle_oauth_error(http_error) def _add_custom_values_to_token_info(self, token_info): """ Store some values that aren't directly provided by a Web API response. """ token_info["expires_at"] = int(time.time()) + token_info["expires_in"] return token_info class SpotifyOAuth(SpotifyAuthBase): """ Implements Authorization Code Flow for Spotify's OAuth implementation. """ OAUTH_AUTHORIZE_URL = "https://accounts.spotify.com/authorize" OAUTH_TOKEN_URL = "https://accounts.spotify.com/api/token" def __init__( self, client_id=None, client_secret=None, redirect_uri=None, state=None, scope=None, cache_path=None, username=None, proxies=None, show_dialog=False, requests_session=True, requests_timeout=None, open_browser=True, cache_handler=None ): """ Creates a SpotifyOAuth object Parameters: * client_id: Must be supplied or set as environment variable * client_secret: Must be supplied or set as environment variable * redirect_uri: Must be supplied or set as environment variable * state: Optional, no verification is performed * scope: Optional, either a list of scopes or comma separated string of scopes. e.g, "playlist-read-private,playlist-read-collaborative" * cache_path: (deprecated) Optional, will otherwise be generated (takes precedence over `username`) * username: (deprecated) Optional or set as environment variable (will set `cache_path` to `.cache-{username}`) * proxies: Optional, proxy for the requests library to route through * show_dialog: Optional, interpreted as boolean * requests_session: A Requests session * requests_timeout: Optional, tell Requests to stop waiting for a response after a given number of seconds * open_browser: Optional, whether the web browser should be opened to authorize a user * cache_handler: An instance of the `CacheHandler` class to handle getting and saving cached authorization tokens. Optional, will otherwise use `CacheFileHandler`. (takes precedence over `cache_path` and `username`) """ super().__init__(requests_session) self.client_id = client_id self.client_secret = client_secret self.redirect_uri = redirect_uri self.state = state self.scope = self._normalize_scope(scope) if username or cache_path: warnings.warn("Specifying cache_path or username as arguments to SpotifyOAuth " + "will be deprecated. Instead, please create a CacheFileHandler " + "instance with the desired cache_path and username and pass it " + "to SpotifyOAuth as the cache_handler. For example:\n\n" + "\tfrom spotipy.oauth2 import CacheFileHandler\n" + "\thandler = CacheFileHandler(cache_path=cache_path, " + "username=username)\n" + "\tsp = spotipy.SpotifyOAuth(client_id, client_secret, " + "redirect_uri," + " cache_handler=handler)", DeprecationWarning ) if cache_handler: warnings.warn("A cache_handler has been specified along with a cache_path or " + "username. The cache_path and username arguments will be ignored.") if cache_handler: assert issubclass(cache_handler.__class__, CacheHandler), \ "cache_handler must be a subclass of CacheHandler: " + str(type(cache_handler)) \ + " != " + str(CacheHandler) self.cache_handler = cache_handler else: username = (username or os.getenv(CLIENT_CREDS_ENV_VARS["client_username"])) self.cache_handler = CacheFileHandler( username=username, cache_path=cache_path ) self.proxies = proxies self.requests_timeout = requests_timeout self.show_dialog = show_dialog self.open_browser = open_browser def validate_token(self, token_info): if token_info is None: return None # if scopes don't match, then bail if "scope" not in token_info or not self._is_scope_subset( self.scope, token_info["scope"] ): return None if self.is_token_expired(token_info): token_info = self.refresh_access_token( token_info["refresh_token"] ) return token_info def get_authorize_url(self, state=None): """ Gets the URL to use to authorize this app """ payload = { "client_id": self.client_id, "response_type": "code", "redirect_uri": self.redirect_uri, } if self.scope: payload["scope"] = self.scope if state is None: state = self.state if state is not None: payload["state"] = state if self.show_dialog: payload["show_dialog"] = True urlparams = urllibparse.urlencode(payload) return f"{self.OAUTH_AUTHORIZE_URL}?{urlparams}" def parse_response_code(self, url): """ Parse the response code in the given response url Parameters: - url - the response url """ _, code = self.parse_auth_response_url(url) if code is None: return url else: return code @staticmethod def parse_auth_response_url(url): query_s = urlparse(url).query form = dict(parse_qsl(query_s)) if "error" in form: raise SpotifyOauthError(f"Received error from auth server: {form['error']}", error=form["error"]) return tuple(form.get(param) for param in ["state", "code"]) def _make_authorization_headers(self): return _make_authorization_headers(self.client_id, self.client_secret) def _open_auth_url(self): auth_url = self.get_authorize_url() try: webbrowser.open(auth_url) logger.info("Opened %s in your browser", auth_url) except webbrowser.Error: logger.error("Please navigate here: %s", auth_url) def _get_auth_response_interactive(self, open_browser=False): if open_browser: self._open_auth_url() prompt = "Enter the URL you were redirected to: " else: url = self.get_authorize_url() prompt = ( "Go to the following URL: {}\n" "Enter the URL you were redirected to: ".format(url) ) response = self._get_user_input(prompt) state, code = SpotifyOAuth.parse_auth_response_url(response) if self.state is not None and self.state != state: raise SpotifyStateError(self.state, state) return code def _get_auth_response_local_server(self, redirect_port): server = start_local_http_server(redirect_port) self._open_auth_url() server.handle_request() if server.error is not None: raise server.error elif self.state is not None and server.state != self.state: raise SpotifyStateError(self.state, server.state) elif server.auth_code is not None: return server.auth_code else: raise SpotifyOauthError("Server listening on localhost has not been accessed") def get_auth_response(self, open_browser=None): logger.info('User authentication requires interaction with your ' 'web browser. Once you enter your credentials and ' 'give authorization, you will be redirected to ' 'a url. Paste that url you were directed to to ' 'complete the authorization.') redirect_info = urlparse(self.redirect_uri) redirect_host, redirect_port = get_host_port(redirect_info.netloc) if open_browser is None: open_browser = self.open_browser if ( open_browser and redirect_host in ("127.0.0.1", "localhost") and redirect_info.scheme == "http" ): # Only start a local http server if a port is specified if redirect_port: return self._get_auth_response_local_server(redirect_port) else: logger.warning('Using `%s` as redirect URI without a port. ' 'Specify a port (e.g. `%s:8080`) to allow ' 'automatic retrieval of authentication code ' 'instead of having to copy and paste ' 'the URL your browser is redirected to.', redirect_host, redirect_host) return self._get_auth_response_interactive(open_browser=open_browser) def get_authorization_code(self, response=None): if response: return self.parse_response_code(response) return self.get_auth_response() def get_access_token(self, code=None, as_dict=True, check_cache=True): """ Gets the access token for the app given the code Parameters: - code - the response code - as_dict - a boolean indicating if returning the access token as a token_info dictionary, otherwise it will be returned as a string. """ if as_dict: warnings.warn( "You're using 'as_dict = True'." "get_access_token will return the token string directly in future " "versions. Please adjust your code accordingly, or use " "get_cached_token instead.", DeprecationWarning, stacklevel=2, ) if check_cache: token_info = self.validate_token(self.cache_handler.get_cached_token()) if token_info is not None: if self.is_token_expired(token_info): token_info = self.refresh_access_token( token_info["refresh_token"] ) return token_info if as_dict else token_info["access_token"] payload = { "redirect_uri": self.redirect_uri, "code": code or self.get_auth_response(), "grant_type": "authorization_code", } if self.scope: payload["scope"] = self.scope if self.state: payload["state"] = self.state headers = self._make_authorization_headers() logger.debug( "sending POST request to %s with Headers: %s and Body: %r", self.OAUTH_TOKEN_URL, headers, payload ) try: response = self._session.post( self.OAUTH_TOKEN_URL, data=payload, headers=headers, verify=True, proxies=self.proxies, timeout=self.requests_timeout, ) response.raise_for_status() token_info = response.json() token_info = self._add_custom_values_to_token_info(token_info) self.cache_handler.save_token_to_cache(token_info) return token_info if as_dict else token_info["access_token"] except requests.exceptions.HTTPError as http_error: self._handle_oauth_error(http_error) def refresh_access_token(self, refresh_token): payload = { "refresh_token": refresh_token, "grant_type": "refresh_token", } headers = self._make_authorization_headers() logger.debug( "sending POST request to %s with Headers: %s and Body: %r", self.OAUTH_TOKEN_URL, headers, payload ) try: response = self._session.post( self.OAUTH_TOKEN_URL, data=payload, headers=headers, proxies=self.proxies, timeout=self.requests_timeout, ) response.raise_for_status() token_info = response.json() token_info = self._add_custom_values_to_token_info(token_info) if "refresh_token" not in token_info: token_info["refresh_token"] = refresh_token self.cache_handler.save_token_to_cache(token_info) return token_info except requests.exceptions.HTTPError as http_error: self._handle_oauth_error(http_error) def _add_custom_values_to_token_info(self, token_info): """ Store some values that aren't directly provided by a Web API response. """ token_info["expires_at"] = int(time.time()) + token_info["expires_in"] token_info["scope"] = self.scope return token_info def get_cached_token(self): warnings.warn("Calling get_cached_token directly on the SpotifyOAuth object will be " + "deprecated. Instead, please specify a CacheFileHandler instance as " + "the cache_handler in SpotifyOAuth and use the CacheFileHandler's " + "get_cached_token method. You can replace:\n\tsp.get_cached_token()" + "\n\nWith:\n\tsp.validate_token(sp.cache_handler.get_cached_token())", DeprecationWarning ) return self.validate_token(self.cache_handler.get_cached_token()) def _save_token_info(self, token_info): warnings.warn("Calling _save_token_info directly on the SpotifyOAuth object will be " + "deprecated. Instead, please specify a CacheFileHandler instance as " + "the cache_handler in SpotifyOAuth and use the CacheFileHandler's " + "save_token_to_cache method.", DeprecationWarning ) self.cache_handler.save_token_to_cache(token_info) return None class SpotifyPKCE(SpotifyAuthBase): """ Implements PKCE Authorization Flow for client apps This auth manager enables *user and non-user* endpoints with only a client ID, redirect URI, and username. When the app requests an access token for the first time, the user is prompted to authorize the new client app. After authorizing the app, the client app is then given both access and refresh tokens. This is the preferred way of authorizing a mobile/desktop client. """ OAUTH_AUTHORIZE_URL = "https://accounts.spotify.com/authorize" OAUTH_TOKEN_URL = "https://accounts.spotify.com/api/token" def __init__(self, client_id=None, redirect_uri=None, state=None, scope=None, cache_path=None, username=None, proxies=None, requests_timeout=None, requests_session=True, open_browser=True, cache_handler=None): """ Creates Auth Manager with the PKCE Auth flow. Parameters: * client_id: Must be supplied or set as environment variable * redirect_uri: Must be supplied or set as environment variable * state: Optional, no verification is performed * scope: Optional, either a list of scopes or comma separated string of scopes. e.g, "playlist-read-private,playlist-read-collaborative" * cache_path: (deprecated) Optional, will otherwise be generated (takes precedence over `username`) * username: (deprecated) Optional or set as environment variable (will set `cache_path` to `.cache-{username}`) * proxies: Optional, proxy for the requests library to route through * requests_timeout: Optional, tell Requests to stop waiting for a response after a given number of seconds * requests_session: A Requests session * open_browser: Optional, whether the web browser should be opened to authorize a user * cache_handler: An instance of the `CacheHandler` class to handle getting and saving cached authorization tokens. Optional, will otherwise use `CacheFileHandler`. (takes precedence over `cache_path` and `username`) """ super().__init__(requests_session) self.client_id = client_id self.redirect_uri = redirect_uri self.state = state self.scope = self._normalize_scope(scope) if username or cache_path: warnings.warn("Specifying cache_path or username as arguments to SpotifyPKCE " + "will be deprecated. Instead, please create a CacheFileHandler " + "instance with the desired cache_path and username and pass it " + "to SpotifyPKCE as the cache_handler. For example:\n\n" + "\tfrom spotipy.oauth2 import CacheFileHandler\n" + "\thandler = CacheFileHandler(cache_path=cache_path, " + "username=username)\n" + "\tsp = spotipy.SpotifyImplicitGrant(client_id, client_secret, " + "redirect_uri, cache_handler=handler)", DeprecationWarning ) if cache_handler: warnings.warn("A cache_handler has been specified along with a cache_path or " + "username. The cache_path and username arguments will be ignored.") if cache_handler: assert issubclass(type(cache_handler), CacheHandler), \ "type(cache_handler): " + str(type(cache_handler)) + " != " + str(CacheHandler) self.cache_handler = cache_handler else: username = (username or os.getenv(CLIENT_CREDS_ENV_VARS["client_username"])) self.cache_handler = CacheFileHandler( username=username, cache_path=cache_path ) self.proxies = proxies self.requests_timeout = requests_timeout self._code_challenge_method = "S256" # Spotify requires SHA256 self.code_verifier = None self.code_challenge = None self.authorization_code = None self.open_browser = open_browser def _get_code_verifier(self): """ Spotify PCKE code verifier - See step 1 of the reference guide below Reference: https://developer.spotify.com/documentation/general/guides/authorization-guide/#authorization-code-flow-with-proof-key-for-code-exchange-pkce """ # Range (33,96) is used to select between 44-128 base64 characters for the # next operation. The range looks weird because base64 is 6 bytes import random length = random.randint(33, 96) # The seeded length generates between a 44 and 128 base64 characters encoded string import secrets return secrets.token_urlsafe(length) def _get_code_challenge(self): """ Spotify PCKE code challenge - See step 1 of the reference guide below Reference: https://developer.spotify.com/documentation/general/guides/authorization-guide/#authorization-code-flow-with-proof-key-for-code-exchange-pkce """ import base64 import hashlib code_challenge_digest = hashlib.sha256(self.code_verifier.encode('utf-8')).digest() code_challenge = base64.urlsafe_b64encode(code_challenge_digest).decode('utf-8') return code_challenge.replace('=', '') def get_authorize_url(self, state=None): """ Gets the URL to use to authorize this app """ if not self.code_challenge: self.get_pkce_handshake_parameters() payload = { "client_id": self.client_id, "response_type": "code", "redirect_uri": self.redirect_uri, "code_challenge_method": self._code_challenge_method, "code_challenge": self.code_challenge } if self.scope: payload["scope"] = self.scope if state is None: state = self.state if state is not None: payload["state"] = state urlparams = urllibparse.urlencode(payload) return f"{self.OAUTH_AUTHORIZE_URL}?{urlparams}" def _open_auth_url(self, state=None): auth_url = self.get_authorize_url(state) try: webbrowser.open(auth_url) logger.info("Opened %s in your browser", auth_url) except webbrowser.Error: logger.error("Please navigate here: %s", auth_url) def _get_auth_response(self, open_browser=None): logger.info('User authentication requires interaction with your ' 'web browser. Once you enter your credentials and ' 'give authorization, you will be redirected to ' 'a url. Paste that url you were directed to to ' 'complete the authorization.') redirect_info = urlparse(self.redirect_uri) redirect_host, redirect_port = get_host_port(redirect_info.netloc) if open_browser is None: open_browser = self.open_browser if ( open_browser and redirect_host in ("127.0.0.1", "localhost") and redirect_info.scheme == "http" ): # Only start a local http server if a port is specified if redirect_port: return self._get_auth_response_local_server(redirect_port) else: logger.warning('Using `%s` as redirect URI without a port. ' 'Specify a port (e.g. `%s:8080`) to allow ' 'automatic retrieval of authentication code ' 'instead of having to copy and paste ' 'the URL your browser is redirected to.', redirect_host, redirect_host) return self._get_auth_response_interactive(open_browser=open_browser) def _get_auth_response_local_server(self, redirect_port): server = start_local_http_server(redirect_port) self._open_auth_url() server.handle_request() if self.state is not None and server.state != self.state: raise SpotifyStateError(self.state, server.state) if server.auth_code is not None: return server.auth_code elif server.error is not None: raise SpotifyOauthError(f"Received error from OAuth server: {server.error}") else: raise SpotifyOauthError("Server listening on localhost has not been accessed") def _get_auth_response_interactive(self, open_browser=False): if open_browser or self.open_browser: self._open_auth_url() prompt = "Enter the URL you were redirected to: " else: url = self.get_authorize_url() prompt = ( "Go to the following URL: {}\n" "Enter the URL you were redirected to: ".format(url) ) response = self._get_user_input(prompt) state, code = self.parse_auth_response_url(response) if self.state is not None and self.state != state: raise SpotifyStateError(self.state, state) return code def get_authorization_code(self, response=None): if response: return self.parse_response_code(response) return self._get_auth_response() def validate_token(self, token_info): if token_info is None: return None # if scopes don't match, then bail if "scope" not in token_info or not self._is_scope_subset( self.scope, token_info["scope"] ): return None if self.is_token_expired(token_info): token_info = self.refresh_access_token( token_info["refresh_token"] ) return token_info def _add_custom_values_to_token_info(self, token_info): """ Store some values that aren't directly provided by a Web API response. """ token_info["expires_at"] = int(time.time()) + token_info["expires_in"] return token_info def get_pkce_handshake_parameters(self): self.code_verifier = self._get_code_verifier() self.code_challenge = self._get_code_challenge() def get_access_token(self, code=None, check_cache=True): """ Gets the access token for the app If the code is not given and no cached token is used, an authentication window will be shown to the user to get a new code. Parameters: - code - the response code from authentication - check_cache - if true, checks for a locally stored token before requesting a new token """ if check_cache: token_info = self.validate_token(self.cache_handler.get_cached_token()) if token_info is not None: if self.is_token_expired(token_info): token_info = self.refresh_access_token( token_info["refresh_token"] ) return token_info["access_token"] if self.code_verifier is None or self.code_challenge is None: self.get_pkce_handshake_parameters() payload = { "client_id": self.client_id, "grant_type": "authorization_code", "code": code or self.get_authorization_code(), "redirect_uri": self.redirect_uri, "code_verifier": self.code_verifier } headers = {"Content-Type": "application/x-www-form-urlencoded"} logger.debug( "sending POST request to %s with Headers: %s and Body: %r", self.OAUTH_TOKEN_URL, headers, payload ) try: response = self._session.post( self.OAUTH_TOKEN_URL, data=payload, headers=headers, verify=True, proxies=self.proxies, timeout=self.requests_timeout, ) response.raise_for_status() token_info = response.json() token_info = self._add_custom_values_to_token_info(token_info) self.cache_handler.save_token_to_cache(token_info) return token_info["access_token"] except requests.exceptions.HTTPError as http_error: self._handle_oauth_error(http_error) def refresh_access_token(self, refresh_token): payload = { "refresh_token": refresh_token, "grant_type": "refresh_token", "client_id": self.client_id, } headers = {"Content-Type": "application/x-www-form-urlencoded"} logger.debug( "sending POST request to %s with Headers: %s and Body: %r", self.OAUTH_TOKEN_URL, headers, payload ) try: response = self._session.post( self.OAUTH_TOKEN_URL, data=payload, headers=headers, proxies=self.proxies, timeout=self.requests_timeout, ) response.raise_for_status() token_info = response.json() token_info = self._add_custom_values_to_token_info(token_info) if "refresh_token" not in token_info: token_info["refresh_token"] = refresh_token self.cache_handler.save_token_to_cache(token_info) return token_info except requests.exceptions.HTTPError as http_error: self._handle_oauth_error(http_error) def parse_response_code(self, url): """ Parse the response code in the given response url Parameters: - url - the response url """ _, code = self.parse_auth_response_url(url) if code is None: return url else: return code @staticmethod def parse_auth_response_url(url): return SpotifyOAuth.parse_auth_response_url(url) def get_cached_token(self): warnings.warn("Calling get_cached_token directly on the SpotifyPKCE object will be " + "deprecated. Instead, please specify a CacheFileHandler instance as " + "the cache_handler in SpotifyOAuth and use the CacheFileHandler's " + "get_cached_token method. You can replace:\n\tsp.get_cached_token()" + "\n\nWith:\n\tsp.validate_token(sp.cache_handler.get_cached_token())", DeprecationWarning ) return self.validate_token(self.cache_handler.get_cached_token()) def _save_token_info(self, token_info): warnings.warn("Calling _save_token_info directly on the SpotifyOAuth object will be " + "deprecated. Instead, please specify a CacheFileHandler instance as " + "the cache_handler in SpotifyOAuth and use the CacheFileHandler's " + "save_token_to_cache method.", DeprecationWarning ) self.cache_handler.save_token_to_cache(token_info) return None class SpotifyImplicitGrant(SpotifyAuthBase): """ Implements Implicit Grant Flow for client apps This auth manager enables *user and non-user* endpoints with only a client secret, redirect uri, and username. The user will need to copy and paste a URI from the browser every hour. Security Warning ----------------- The OAuth standard no longer recommends the Implicit Grant Flow for client-side code. Spotify has implemented the OAuth-suggested PKCE extension that removes the need for a client secret in the Authentication Code flow. Use the SpotifyPKCE auth manager instead of SpotifyImplicitGrant. SpotifyPKCE contains all the functionality of SpotifyImplicitGrant, plus automatic response retrieval and refreshable tokens. Only a few replacements need to be made: * get_auth_response()['access_token'] -> get_access_token(get_authorization_code()) * get_auth_response() -> get_access_token(get_authorization_code()); get_cached_token() * parse_response_token(url)['access_token'] -> get_access_token(parse_response_code(url)) * parse_response_token(url) -> get_access_token(parse_response_code(url)); get_cached_token() The security concern in the Implicit Grant flow is that the token is returned in the URL and can be intercepted through the browser. A request with an authorization code and proof of origin could not be easily intercepted without a compromised network. """ OAUTH_AUTHORIZE_URL = "https://accounts.spotify.com/authorize" def __init__(self, client_id=None, redirect_uri=None, state=None, scope=None, cache_path=None, username=None, show_dialog=False, cache_handler=None): """ Creates Auth Manager using the Implicit Grant flow **See help(SpotifyImplicitGrant) for full Security Warning** Parameters ---------- * client_id: Must be supplied or set as environment variable * redirect_uri: Must be supplied or set as environment variable * state: May be supplied, no verification is performed * scope: Optional, either a list of scopes or comma separated string of scopes. e.g, "playlist-read-private,playlist-read-collaborative" * cache_handler: An instance of the `CacheHandler` class to handle getting and saving cached authorization tokens. May be supplied, will otherwise use `CacheFileHandler`. (takes precedence over `cache_path` and `username`) * cache_path: (deprecated) May be supplied, will otherwise be generated (takes precedence over `username`) * username: (deprecated) May be supplied or set as environment variable (will set `cache_path` to `.cache-{username}`) * show_dialog: Interpreted as boolean """ logger.warning("The OAuth standard no longer recommends the Implicit " "Grant Flow for client-side code. Use the SpotifyPKCE " "auth manager instead of SpotifyImplicitGrant. For " "more details and a guide to switching, see " "help(SpotifyImplicitGrant).") self.client_id = client_id self.redirect_uri = redirect_uri self.state = state if username or cache_path: warnings.warn("Specifying cache_path or username as arguments to " + "SpotifyImplicitGrant will be deprecated. Instead, please create " + "a CacheFileHandler instance with the desired cache_path and " + "username and pass it to SpotifyImplicitGrant as the " + "cache_handler. For example:\n\n" + "\tfrom spotipy.oauth2 import CacheFileHandler\n" + "\thandler = CacheFileHandler(cache_path=cache_path, " + "username=username)\n" + "\tsp = spotipy.SpotifyImplicitGrant(client_id, client_secret, " + "redirect_uri, cache_handler=handler)", DeprecationWarning ) if cache_handler: warnings.warn("A cache_handler has been specified along with a cache_path or " + "username. The cache_path and username arguments will be ignored.") if cache_handler: assert issubclass(type(cache_handler), CacheHandler), \ "type(cache_handler): " + str(type(cache_handler)) + " != " + str(CacheHandler) self.cache_handler = cache_handler else: username = (username or os.getenv(CLIENT_CREDS_ENV_VARS["client_username"])) self.cache_handler = CacheFileHandler( username=username, cache_path=cache_path ) self.scope = self._normalize_scope(scope) self.show_dialog = show_dialog self._session = None # As to not break inherited __del__ def validate_token(self, token_info): if token_info is None: return None # if scopes don't match, then bail if "scope" not in token_info or not self._is_scope_subset( self.scope, token_info["scope"] ): return None if self.is_token_expired(token_info): return None return token_info def get_access_token(self, state=None, response=None, check_cache=True): """ Gets Auth Token from cache (preferred) or user interaction Parameters ---------- * state: May be given, overrides (without changing) self.state * response: URI with token, can break expiration checks * check_cache: Interpreted as boolean """ if check_cache: token_info = self.validate_token(self.cache_handler.get_cached_token()) if not (token_info is None or self.is_token_expired(token_info)): return token_info["access_token"] if response: token_info = self.parse_response_token(response) else: token_info = self.get_auth_response(state) token_info = self._add_custom_values_to_token_info(token_info) self.cache_handler.save_token_to_cache(token_info) return token_info["access_token"] def get_authorize_url(self, state=None): """ Gets the URL to use to authorize this app """ payload = { "client_id": self.client_id, "response_type": "token", "redirect_uri": self.redirect_uri, } if self.scope: payload["scope"] = self.scope if state is None: state = self.state if state is not None: payload["state"] = state if self.show_dialog: payload["show_dialog"] = True urlparams = urllibparse.urlencode(payload) return f"{self.OAUTH_AUTHORIZE_URL}?{urlparams}" def parse_response_token(self, url, state=None): """ Parse the response code in the given response url """ remote_state, token, t_type, exp_in = self.parse_auth_response_url(url) if state is None: state = self.state if state is not None and remote_state != state: raise SpotifyStateError(state, remote_state) return {"access_token": token, "token_type": t_type, "expires_in": exp_in, "state": state} @staticmethod def parse_auth_response_url(url): url_components = urlparse(url) fragment_s = url_components.fragment query_s = url_components.query form = dict(i.split('=') for i in (fragment_s or query_s or url).split('&')) if "error" in form: raise SpotifyOauthError(f"Received error from auth server: {form['error']}", state=form["state"]) if "expires_in" in form: form["expires_in"] = int(form["expires_in"]) return tuple(form.get(param) for param in ["state", "access_token", "token_type", "expires_in"]) def _open_auth_url(self, state=None): auth_url = self.get_authorize_url(state) try: webbrowser.open(auth_url) logger.info("Opened %s in your browser", auth_url) except webbrowser.Error: logger.error("Please navigate here: %s", auth_url) def get_auth_response(self, state=None): """ Gets a new auth **token** with user interaction """ logger.info('User authentication requires interaction with your ' 'web browser. Once you enter your credentials and ' 'give authorization, you will be redirected to ' 'a url. Paste that url you were directed to to ' 'complete the authorization.') redirect_info = urlparse(self.redirect_uri) redirect_host, redirect_port = get_host_port(redirect_info.netloc) # Implicit Grant tokens are returned in a hash fragment # which is only available to the browser. Therefore, interactive # URL retrieval is required. if (redirect_host in ("127.0.0.1", "localhost") and redirect_info.scheme == "http" and redirect_port): logger.warning('Using a local redirect URI with a ' 'port, likely expecting automatic ' 'retrieval. Due to technical limitations, ' 'the authentication token cannot be ' 'automatically retrieved and must be ' 'copied and pasted.') self._open_auth_url(state) logger.info('Paste that url you were directed to in order to ' 'complete the authorization') response = SpotifyImplicitGrant._get_user_input("Enter the URL you " "were redirected to: ") return self.parse_response_token(response, state) def _add_custom_values_to_token_info(self, token_info): """ Store some values that aren't directly provided by a Web API response. """ token_info["expires_at"] = int(time.time()) + token_info["expires_in"] token_info["scope"] = self.scope return token_info def get_cached_token(self): warnings.warn("Calling get_cached_token directly on the SpotifyImplicitGrant " + "object will be deprecated. Instead, please specify a " + "CacheFileHandler instance as the cache_handler in SpotifyOAuth " + "and use the CacheFileHandler's get_cached_token method. " + "You can replace:\n\tsp.get_cached_token()" + "\n\nWith:\n\tsp.validate_token(sp.cache_handler.get_cached_token())", DeprecationWarning ) return self.validate_token(self.cache_handler.get_cached_token()) def _save_token_info(self, token_info): warnings.warn("Calling _save_token_info directly on the SpotifyImplicitGrant " + "object will be deprecated. Instead, please specify a " + "CacheFileHandler instance as the cache_handler in SpotifyOAuth " + "and use the CacheFileHandler's save_token_to_cache method.", DeprecationWarning ) self.cache_handler.save_token_to_cache(token_info) return None class RequestHandler(BaseHTTPRequestHandler): def do_GET(self): self.server.auth_code = self.server.error = None try: state, auth_code = SpotifyOAuth.parse_auth_response_url(self.path) self.server.state = state self.server.auth_code = auth_code except SpotifyOauthError as error: self.server.error = error self.send_response(200) self.send_header("Content-Type", "text/html") self.end_headers() if self.server.auth_code: status = "successful" elif self.server.error: status = f"failed ({self.server.error})" else: self._write("

Invalid request

") return self._write("""

Authentication status: {}

This window can be closed. """.format(status)) def _write(self, text): return self.wfile.write(text.encode("utf-8")) def log_message(self, format, *args): return def start_local_http_server(port, handler=RequestHandler): server = HTTPServer(("127.0.0.1", port), handler) server.allow_reuse_address = True server.auth_code = None server.auth_token_form = None server.error = None return server spotipy-dev-spotipy-8a40e03/spotipy/util.py000066400000000000000000000076031462616510500211020ustar00rootroot00000000000000""" Shows a user's playlists (need to be authenticated via oauth) """ __all__ = ["CLIENT_CREDS_ENV_VARS", "prompt_for_user_token"] import logging import os import warnings import spotipy LOGGER = logging.getLogger(__name__) CLIENT_CREDS_ENV_VARS = { "client_id": "SPOTIPY_CLIENT_ID", "client_secret": "SPOTIPY_CLIENT_SECRET", "client_username": "SPOTIPY_CLIENT_USERNAME", "redirect_uri": "SPOTIPY_REDIRECT_URI", } def prompt_for_user_token( username=None, scope=None, client_id=None, client_secret=None, redirect_uri=None, cache_path=None, oauth_manager=None, show_dialog=False ): warnings.warn( "'prompt_for_user_token' is deprecated." "Use the following instead: " " auth_manager=SpotifyOAuth(scope=scope)" " spotipy.Spotify(auth_manager=auth_manager)", DeprecationWarning ) """ prompts the user to login if necessary and returns the user token suitable for use with the spotipy.Spotify constructor Parameters: - username - the Spotify username (optional) - scope - the desired scope of the request (optional) - client_id - the client id of your app (required) - client_secret - the client secret of your app (required) - redirect_uri - the redirect URI of your app (required) - cache_path - path to location to save tokens (optional) - oauth_manager - Oauth manager object (optional) - show_dialog - If true, a login prompt always shows (optional, defaults to False) """ if not oauth_manager: if not client_id: client_id = os.getenv("SPOTIPY_CLIENT_ID") if not client_secret: client_secret = os.getenv("SPOTIPY_CLIENT_SECRET") if not redirect_uri: redirect_uri = os.getenv("SPOTIPY_REDIRECT_URI") if not client_id: LOGGER.warning( """ You need to set your Spotify API credentials. You can do this by setting environment variables like so: export SPOTIPY_CLIENT_ID='your-spotify-client-id' export SPOTIPY_CLIENT_SECRET='your-spotify-client-secret' export SPOTIPY_REDIRECT_URI='your-app-redirect-url' Get your credentials at https://developer.spotify.com/my-applications """ ) raise spotipy.SpotifyException(550, -1, "no credentials set") sp_oauth = oauth_manager or spotipy.SpotifyOAuth( client_id, client_secret, redirect_uri, scope=scope, cache_path=cache_path, username=username, show_dialog=show_dialog ) # try to get a valid token for this user, from the cache, # if not in the cache, then create a new (this will send # the user to a web page where they can authorize this app) token_info = sp_oauth.validate_token(sp_oauth.cache_handler.get_cached_token()) if not token_info: code = sp_oauth.get_auth_response() token = sp_oauth.get_access_token(code, as_dict=False) else: return token_info["access_token"] # Auth'ed API request if token: return token else: return None def get_host_port(netloc): if ":" in netloc: host, port = netloc.split(":", 1) port = int(port) else: host = netloc port = None return host, port def normalize_scope(scope): if scope: if isinstance(scope, str): scopes = scope.split(',') elif isinstance(scope, list) or isinstance(scope, tuple): scopes = scope else: raise Exception( "Unsupported scope value, please either provide a list of scopes, " "or a string of scopes separated by commas" ) return " ".join(sorted(scopes)) else: return None spotipy-dev-spotipy-8a40e03/tests/000077500000000000000000000000001462616510500172005ustar00rootroot00000000000000spotipy-dev-spotipy-8a40e03/tests/__init__.py000066400000000000000000000000001462616510500212770ustar00rootroot00000000000000spotipy-dev-spotipy-8a40e03/tests/helpers.py000066400000000000000000000006661462616510500212240ustar00rootroot00000000000000import base64 import requests def get_spotify_playlist(spotify_object, playlist_name, username): playlists = spotify_object.user_playlists(username) while playlists: for item in playlists['items']: if item['name'] == playlist_name: return item playlists = spotify_object.next(playlists) def get_as_base64(url): return base64.b64encode(requests.get(url).content).decode("utf-8") spotipy-dev-spotipy-8a40e03/tests/integration/000077500000000000000000000000001462616510500215235ustar00rootroot00000000000000spotipy-dev-spotipy-8a40e03/tests/integration/__init__.py000066400000000000000000000000001462616510500236220ustar00rootroot00000000000000spotipy-dev-spotipy-8a40e03/tests/integration/non_user_endpoints/000077500000000000000000000000001462616510500254365ustar00rootroot00000000000000spotipy-dev-spotipy-8a40e03/tests/integration/non_user_endpoints/__init__.py000066400000000000000000000000001462616510500275350ustar00rootroot00000000000000spotipy-dev-spotipy-8a40e03/tests/integration/non_user_endpoints/test.py000066400000000000000000000530131462616510500267710ustar00rootroot00000000000000from spotipy import ( Spotify, SpotifyClientCredentials, SpotifyException ) import spotipy import unittest import requests class AuthTestSpotipy(unittest.TestCase): """ These tests require client authentication - provide client credentials using the following environment variables :: 'SPOTIPY_CLIENT_ID' 'SPOTIPY_CLIENT_SECRET' """ playlist = "spotify:user:plamere:playlist:2oCEWyyAPbZp9xhVSxZavx" four_tracks = ["spotify:track:6RtPijgfPKROxEzTHNRiDp", "spotify:track:7IHOIqZUUInxjVkko181PB", "4VrWlk8IQxevMvERoX08iC", "http://open.spotify.com/track/3cySlItpiPiIAzU3NyHCJf"] two_tracks = ["spotify:track:6RtPijgfPKROxEzTHNRiDp", "spotify:track:7IHOIqZUUInxjVkko181PB"] other_tracks = ["spotify:track:2wySlB6vMzCbQrRnNGOYKa", "spotify:track:29xKs5BAHlmlX1u4gzQAbJ", "spotify:track:1PB7gRWcvefzu7t3LJLUlf"] bad_id = 'BAD_ID' creep_urn = 'spotify:track:6b2oQwSGFkzsMtQruIWm2p' creep_id = '6b2oQwSGFkzsMtQruIWm2p' creep_url = 'http://open.spotify.com/track/6b2oQwSGFkzsMtQruIWm2p' el_scorcho_urn = 'spotify:track:0Svkvt5I79wficMFgaqEQJ' el_scorcho_bad_urn = 'spotify:track:0Svkvt5I79wficMFgaqEQK' pinkerton_urn = 'spotify:album:04xe676vyiTeYNXw15o9jT' weezer_urn = 'spotify:artist:3jOstUTkEu2JkjvRdBA5Gu' pablo_honey_urn = 'spotify:album:6AZv3m27uyRxi8KyJSfUxL' radiohead_urn = 'spotify:artist:4Z8W4fKeB5YxbusRsdQVPb' angeles_haydn_urn = 'spotify:album:1vAbqAeuJVWNAe7UR00bdM' heavyweight_urn = 'spotify:show:5c26B28vZMN8PG0Nppmn5G' heavyweight_id = '5c26B28vZMN8PG0Nppmn5G' heavyweight_url = 'https://open.spotify.com/show/5c26B28vZMN8PG0Nppmn5G' reply_all_urn = 'spotify:show:7gozmLqbcbr6PScMjc0Zl4' heavyweight_ep1_urn = 'spotify:episode:68kq3bNz6hEuq8NtdfwERG' heavyweight_ep1_id = '68kq3bNz6hEuq8NtdfwERG' heavyweight_ep1_url = 'https://open.spotify.com/episode/68kq3bNz6hEuq8NtdfwERG' reply_all_ep1_urn = 'spotify:episode:1KHjbpnmNpFmNTczQmTZlR' american_gods_urn = 'spotify:audiobook:1IcM9Untg6d3ktuwObYGcN' american_gods_id = '1IcM9Untg6d3ktuwObYGcN' american_gods_url = 'https://open.spotify.com/audiobook/1IcM9Untg6d3ktuwObYGcN' four_books = [ 'spotify:audiobook:1IcM9Untg6d3ktuwObYGcN', 'spotify:audiobook:37sRC6carIX2Vf3Vv716T7', 'spotify:audiobook:1Gep4UJ95xQawA55OgRI8n', 'spotify:audiobook:4Sm381mcf5gBsi9yfhqgVB'] @classmethod def setUpClass(self): self.spotify = Spotify( client_credentials_manager=SpotifyClientCredentials()) self.spotify.trace = False def test_audio_analysis(self): result = self.spotify.audio_analysis(self.four_tracks[0]) assert ('beats' in result) def test_audio_features(self): results = self.spotify.audio_features(self.four_tracks) self.assertTrue(len(results) == len(self.four_tracks)) for track in results: assert ('speechiness' in track) def test_audio_features_with_bad_track(self): bad_tracks = ['spotify:track:bad'] input = self.four_tracks + bad_tracks results = self.spotify.audio_features(input) self.assertTrue(len(results) == len(input)) for track in results[:-1]: if track is not None: assert ('speechiness' in track) self.assertTrue(results[-1] is None) def test_recommendations(self): results = self.spotify.recommendations( seed_tracks=self.four_tracks, min_danceability=0, max_loudness=0, target_popularity=50) self.assertTrue(len(results['tracks']) == 20) def test_artist_urn(self): artist = self.spotify.artist(self.radiohead_urn) self.assertTrue(artist['name'] == 'Radiohead') def test_artists(self): results = self.spotify.artists([self.weezer_urn, self.radiohead_urn]) self.assertTrue('artists' in results) self.assertTrue(len(results['artists']) == 2) def test_album_urn(self): album = self.spotify.album(self.pinkerton_urn) self.assertTrue(album['name'] == 'Pinkerton') def test_album_tracks(self): results = self.spotify.album_tracks(self.pinkerton_urn) self.assertTrue(len(results['items']) == 10) def test_album_tracks_many(self): results = self.spotify.album_tracks(self.angeles_haydn_urn) tracks = results['items'] total, received = results['total'], len(tracks) while received < total: results = self.spotify.album_tracks( self.angeles_haydn_urn, offset=received) tracks.extend(results['items']) received = len(tracks) self.assertEqual(received, total) def test_albums(self): results = self.spotify.albums( [self.pinkerton_urn, self.pablo_honey_urn]) self.assertTrue('albums' in results) self.assertTrue(len(results['albums']) == 2) def test_track_urn(self): track = self.spotify.track(self.creep_urn) self.assertTrue(track['name'] == 'Creep') def test_track_id(self): track = self.spotify.track(self.creep_id) self.assertTrue(track['name'] == 'Creep') self.assertTrue(track['popularity'] > 0) def test_track_url(self): track = self.spotify.track(self.creep_url) self.assertTrue(track['name'] == 'Creep') def test_track_bad_urn(self): try: self.spotify.track(self.el_scorcho_bad_urn) self.assertTrue(False) except SpotifyException: self.assertTrue(True) def test_tracks(self): results = self.spotify.tracks([self.creep_url, self.el_scorcho_urn]) self.assertTrue('tracks' in results) self.assertTrue(len(results['tracks']) == 2) def test_artist_top_tracks(self): results = self.spotify.artist_top_tracks(self.weezer_urn) self.assertTrue('tracks' in results) self.assertTrue(len(results['tracks']) == 10) def test_artist_related_artists(self): results = self.spotify.artist_related_artists(self.weezer_urn) self.assertTrue('artists' in results) self.assertTrue(len(results['artists']) == 20) found = False for artist in results['artists']: if artist['name'] == 'Jimmy Eat World': found = True self.assertTrue(found) def test_artist_search(self): results = self.spotify.search(q='weezer', type='artist') self.assertTrue('artists' in results) self.assertTrue(len(results['artists']['items']) > 0) self.assertTrue(results['artists']['items'][0]['name'] == 'Weezer') def test_artist_search_with_market(self): results = self.spotify.search(q='weezer', type='artist', market='GB') self.assertTrue('artists' in results) self.assertTrue(len(results['artists']['items']) > 0) self.assertTrue(results['artists']['items'][0]['name'] == 'Weezer') def test_artist_search_with_multiple_markets(self): total = 5 countries_list = ['GB', 'US', 'AU'] countries_tuple = ('GB', 'US', 'AU') results_multiple = self.spotify.search_markets(q='weezer', type='artist', markets=countries_list) results_all = self.spotify.search_markets(q='weezer', type='artist') results_tuple = self.spotify.search_markets(q='weezer', type='artist', markets=countries_tuple) results_limited = self.spotify.search_markets(q='weezer', limit=3, type='artist', markets=countries_list, total=total) self.assertTrue( all('artists' in results_multiple[country] for country in results_multiple)) self.assertTrue(all('artists' in results_all[country] for country in results_all)) self.assertTrue(all('artists' in results_tuple[country] for country in results_tuple)) self.assertTrue(all('artists' in results_limited[country] for country in results_limited)) self.assertTrue( all(len(results_multiple[country]['artists']['items']) > 0 for country in results_multiple)) self.assertTrue(all(len(results_all[country]['artists'] ['items']) > 0 for country in results_all)) self.assertTrue( all(len(results_tuple[country]['artists']['items']) > 0 for country in results_tuple)) self.assertTrue( all(len(results_limited[country]['artists']['items']) > 0 for country in results_limited)) self.assertTrue(all(results_multiple[country]['artists']['items'] [0]['name'] == 'Weezer' for country in results_multiple)) self.assertTrue(all(results_all[country]['artists']['items'] [0]['name'] == 'Weezer' for country in results_all)) self.assertTrue(all(results_tuple[country]['artists']['items'] [0]['name'] == 'Weezer' for country in results_tuple)) self.assertTrue(all(results_limited[country]['artists']['items'] [0]['name'] == 'Weezer' for country in results_limited)) total_limited_results = 0 for country in results_limited: total_limited_results += len(results_limited[country]['artists']['items']) self.assertTrue(total_limited_results <= total) def test_multiple_types_search_with_multiple_markets(self): total = 14 countries_list = ['GB', 'US', 'AU'] countries_tuple = ('GB', 'US', 'AU') results_multiple = self.spotify.search_markets(q='abba', type='artist,track', markets=countries_list) results_all = self.spotify.search_markets(q='abba', type='artist,track') results_tuple = self.spotify.search_markets(q='abba', type='artist,track', markets=countries_tuple) results_limited = self.spotify.search_markets(q='abba', limit=3, type='artist,track', markets=countries_list, total=total) # Asserts 'artists' property is present in all responses self.assertTrue( all('artists' in results_multiple[country] for country in results_multiple)) self.assertTrue(all('artists' in results_all[country] for country in results_all)) self.assertTrue(all('artists' in results_tuple[country] for country in results_tuple)) self.assertTrue(all('artists' in results_limited[country] for country in results_limited)) # Asserts 'tracks' property is present in all responses self.assertTrue( all('tracks' in results_multiple[country] for country in results_multiple)) self.assertTrue(all('tracks' in results_all[country] for country in results_all)) self.assertTrue(all('tracks' in results_tuple[country] for country in results_tuple)) self.assertTrue(all('tracks' in results_limited[country] for country in results_limited)) # Asserts 'artists' list is nonempty in unlimited searches self.assertTrue( all(len(results_multiple[country]['artists']['items']) > 0 for country in results_multiple)) self.assertTrue(all(len(results_all[country]['artists'] ['items']) > 0 for country in results_all)) self.assertTrue( all(len(results_tuple[country]['artists']['items']) > 0 for country in results_tuple)) # Asserts 'tracks' list is nonempty in unlimited searches self.assertTrue( all(len(results_multiple[country]['tracks']['items']) > 0 for country in results_multiple)) self.assertTrue(all(len(results_all[country]['tracks'] ['items']) > 0 for country in results_all)) self.assertTrue(all(len(results_tuple[country]['tracks'] ['items']) > 0 for country in results_tuple)) # Asserts artist name is the first artist result in all searches self.assertTrue(all(results_multiple[country]['artists']['items'] [0]['name'] == 'ABBA' for country in results_multiple)) self.assertTrue(all(results_all[country]['artists']['items'] [0]['name'] == 'ABBA' for country in results_all)) self.assertTrue(all(results_tuple[country]['artists']['items'] [0]['name'] == 'ABBA' for country in results_tuple)) self.assertTrue(all(results_limited[country]['artists']['items'] [0]['name'] == 'ABBA' for country in results_limited)) # Asserts track name is present in responses from specified markets self.assertTrue(all('Dancing Queen' in [item['name'] for item in results_multiple[country]['tracks']['items']] for country in results_multiple)) self.assertTrue(all('Dancing Queen' in [item['name'] for item in results_tuple[country]['tracks']['items']] for country in results_tuple)) # Asserts expected number of items are returned based on the total # 3 artists + 3 tracks = 6 items returned from first market # 3 artists + 3 tracks = 6 items returned from second market # 2 artists + 0 tracks = 2 items returned from third market # 14 items returned total self.assertEqual(len(results_limited['GB']['artists']['items']), 3) self.assertEqual(len(results_limited['GB']['tracks']['items']), 3) self.assertEqual(len(results_limited['US']['artists']['items']), 3) self.assertEqual(len(results_limited['US']['tracks']['items']), 3) self.assertEqual(len(results_limited['AU']['artists']['items']), 2) self.assertEqual(len(results_limited['AU']['tracks']['items']), 0) item_count = sum([len(market_result['artists']['items']) + len(market_result['tracks'] ['items']) for market_result in results_limited.values()]) self.assertEqual(item_count, total) def test_artist_albums(self): results = self.spotify.artist_albums(self.weezer_urn) self.assertTrue('items' in results) self.assertTrue(len(results['items']) > 0) def find_album(): for album in results['items']: if album['name'] == 'Death to False Metal': return True return False self.assertTrue(find_album()) def test_search_timeout(self): client_credentials_manager = SpotifyClientCredentials() sp = spotipy.Spotify(requests_timeout=0.01, client_credentials_manager=client_credentials_manager) # depending on the timing or bandwidth, this raises a timeout or connection error self.assertRaises((requests.exceptions.Timeout, requests.exceptions.ConnectionError), lambda: sp.search(q='my*', type='track')) @unittest.skip("flaky test, need a better method to test retries") def test_max_retries_reached_get(self): spotify_no_retry = Spotify( client_credentials_manager=SpotifyClientCredentials(), retries=0) i = 0 while i < 100: try: spotify_no_retry.search(q='foo') except SpotifyException as e: self.assertIsInstance(e, SpotifyException) self.assertEqual(e.http_status, 429) return i += 1 self.fail() def test_album_search(self): results = self.spotify.search(q='weezer pinkerton', type='album') self.assertTrue('albums' in results) self.assertTrue(len(results['albums']['items']) > 0) self.assertTrue(results['albums']['items'][0] ['name'].find('Pinkerton') >= 0) def test_track_search(self): results = self.spotify.search(q='el scorcho weezer', type='track') self.assertTrue('tracks' in results) self.assertTrue(len(results['tracks']['items']) > 0) self.assertTrue(results['tracks']['items'][0]['name'] == 'El Scorcho') def test_user(self): user = self.spotify.user(user='plamere') self.assertTrue(user['uri'] == 'spotify:user:plamere') def test_track_bad_id(self): try: self.spotify.track(self.bad_id) self.assertTrue(False) except SpotifyException: self.assertTrue(True) def test_show_urn(self): show = self.spotify.show(self.heavyweight_urn, market="US") self.assertTrue(show['name'] == 'Heavyweight') def test_show_id(self): show = self.spotify.show(self.heavyweight_id, market="US") self.assertTrue(show['name'] == 'Heavyweight') def test_show_url(self): show = self.spotify.show(self.heavyweight_url, market="US") self.assertTrue(show['name'] == 'Heavyweight') def test_show_bad_urn(self): with self.assertRaises(SpotifyException): self.spotify.show("bogus_urn", market="US") def test_shows(self): results = self.spotify.shows([self.heavyweight_urn, self.reply_all_urn], market="US") self.assertTrue('shows' in results) self.assertTrue(len(results['shows']) == 2) def test_show_episodes(self): results = self.spotify.show_episodes(self.heavyweight_urn, market="US") self.assertTrue(len(results['items']) > 1) def test_show_episodes_many(self): results = self.spotify.show_episodes(self.reply_all_urn, market="US") episodes = results['items'] total, received = results['total'], len(episodes) while received < total: results = self.spotify.show_episodes( self.reply_all_urn, offset=received, market="US") episodes.extend(results['items']) received = len(episodes) self.assertEqual(received, total) def test_episode_urn(self): episode = self.spotify.episode(self.heavyweight_ep1_urn, market="US") self.assertTrue(episode['name'] == '#1 Buzz') def test_episode_id(self): episode = self.spotify.episode(self.heavyweight_ep1_id, market="US") self.assertTrue(episode['name'] == '#1 Buzz') def test_episode_url(self): episode = self.spotify.episode(self.heavyweight_ep1_url, market="US") self.assertTrue(episode['name'] == '#1 Buzz') def test_episode_bad_urn(self): with self.assertRaises(SpotifyException): self.spotify.episode("bogus_urn", market="US") def test_episodes(self): results = self.spotify.episodes( [self.heavyweight_ep1_urn, self.reply_all_ep1_urn], market="US" ) self.assertTrue('episodes' in results) self.assertTrue(len(results['episodes']) == 2) def test_unauthenticated_post_fails(self): with self.assertRaises(SpotifyException) as cm: self.spotify.user_playlist_create( "spotify", "Best hits of the 90s") self.assertTrue(cm.exception.http_status == 401 or cm.exception.http_status == 403) def test_custom_requests_session(self): sess = requests.Session() sess.headers["user-agent"] = "spotipy-test" with_custom_session = spotipy.Spotify( client_credentials_manager=SpotifyClientCredentials(), requests_session=sess) self.assertTrue( with_custom_session.user( user="akx")["uri"] == "spotify:user:akx") sess.close() def test_force_no_requests_session(self): with_no_session = spotipy.Spotify( client_credentials_manager=SpotifyClientCredentials(), requests_session=False) self.assertNotIsInstance(with_no_session._session, requests.Session) user = with_no_session.user(user="akx") self.assertEqual(user["uri"], "spotify:user:akx") def test_available_markets(self): markets = self.spotify.available_markets()["markets"] self.assertTrue(isinstance(markets, list)) self.assertIn("US", markets) self.assertIn("GB", markets) def test_get_audiobook(self): audiobook = self.spotify.get_audiobook(self.american_gods_urn, market="US") print(audiobook) self.assertTrue(audiobook['name'] == 'American Gods: The Tenth Anniversary Edition: A Novel') def test_get_audiobook_bad_urn(self): with self.assertRaises(SpotifyException): self.spotify.get_audiobook("bogus_urn", market="US") def test_get_audiobooks(self): results = self.spotify.get_audiobooks(self.four_books, market="US") self.assertTrue('audiobooks' in results) self.assertTrue(len(results['audiobooks']) == 4) self.assertTrue(results['audiobooks'][0]['name'] == 'American Gods: The Tenth Anniversary Edition: A Novel') self.assertTrue(results['audiobooks'][1]['name'] == 'The Da Vinci Code: A Novel') self.assertTrue(results['audiobooks'][2]['name'] == 'Outlander') self.assertTrue(results['audiobooks'][3]['name'] == 'Pachinko: A Novel') def test_get_audiobook_chapters(self): results = self.spotify.get_audiobook_chapters( self.american_gods_urn, market="US", limit=10, offset=5) self.assertTrue('items' in results) self.assertTrue(len(results['items']) == 10) self.assertTrue(results['items'][0]['chapter_number'] == 5) self.assertTrue(results['items'][9]['chapter_number'] == 14) spotipy-dev-spotipy-8a40e03/tests/integration/user_endpoints/000077500000000000000000000000001462616510500245645ustar00rootroot00000000000000spotipy-dev-spotipy-8a40e03/tests/integration/user_endpoints/__init__.py000066400000000000000000000000001462616510500266630ustar00rootroot00000000000000spotipy-dev-spotipy-8a40e03/tests/integration/user_endpoints/test.py000066400000000000000000000515011462616510500261170ustar00rootroot00000000000000import os from spotipy import ( CLIENT_CREDS_ENV_VARS as CCEV, prompt_for_user_token, Spotify, SpotifyException, SpotifyImplicitGrant, SpotifyPKCE ) import unittest from tests import helpers class SpotipyPlaylistApiTest(unittest.TestCase): @classmethod def setUpClass(cls): cls.four_tracks = ["spotify:track:6RtPijgfPKROxEzTHNRiDp", "spotify:track:7IHOIqZUUInxjVkko181PB", "4VrWlk8IQxevMvERoX08iC", "http://open.spotify.com/track/3cySlItpiPiIAzU3NyHCJf"] cls.other_tracks = ["spotify:track:2wySlB6vMzCbQrRnNGOYKa", "spotify:track:29xKs5BAHlmlX1u4gzQAbJ", "spotify:track:1PB7gRWcvefzu7t3LJLUlf"] cls.username = os.getenv(CCEV['client_username']) # be wary here, episodes sometimes go away forever # which could cause tests that rely on four_episodes # to fail cls.four_episodes = [ "spotify:episode:7f9e73vfXKRqR6uCggK2Xy", "spotify:episode:4wA1RLFNOWCJ8iprngXmM0", "spotify:episode:32vhLjJjT7m3f9DFCJUCVZ", "spotify:episode:7cRcsGYYRUFo1OF3RgRzdx", ] scope = ( 'playlist-modify-public ' 'user-library-read ' 'user-follow-read ' 'user-library-modify ' 'user-read-private ' 'user-top-read ' 'user-follow-modify ' 'user-read-recently-played ' 'ugc-image-upload ' 'user-read-playback-state' ) token = prompt_for_user_token(cls.username, scope=scope) cls.spotify = Spotify(auth=token) cls.spotify_no_retry = Spotify(auth=token, retries=0) cls.new_playlist_name = 'spotipy-playlist-test' cls.new_playlist = helpers.get_spotify_playlist( cls.spotify, cls.new_playlist_name, cls.username) or \ cls.spotify.user_playlist_create(cls.username, cls.new_playlist_name) cls.new_playlist_uri = cls.new_playlist['uri'] @classmethod def tearDownClass(cls): cls.spotify.current_user_unfollow_playlist(cls.new_playlist['id']) def test_user_playlists(self): playlists = self.spotify.user_playlists(self.username, limit=5) self.assertTrue('items' in playlists) self.assertGreaterEqual(len(playlists['items']), 1) def test_playlist_items(self): playlists = self.spotify.user_playlists(self.username, limit=5) self.assertTrue('items' in playlists) for playlist in playlists['items']: if playlist['uri'] != self.new_playlist_uri: continue pid = playlist['id'] results = self.spotify.playlist_items(pid) self.assertEqual(len(results['items']), 0) def test_current_user_playlists(self): playlists = self.spotify.current_user_playlists(limit=10) self.assertTrue('items' in playlists) self.assertGreaterEqual(len(playlists['items']), 1) self.assertLessEqual(len(playlists['items']), 10) def test_current_user_follow_playlist(self): playlist_to_follow_id = '4erXB04MxwRAVqcUEpu30O' self.spotify.current_user_follow_playlist(playlist_to_follow_id) follows = self.spotify.playlist_is_following( playlist_to_follow_id, [self.username]) self.assertTrue(len(follows) == 1, 'proper follows length') self.assertTrue(follows[0], 'is following') self.spotify.current_user_unfollow_playlist(playlist_to_follow_id) follows = self.spotify.playlist_is_following( playlist_to_follow_id, [self.username]) self.assertTrue(len(follows) == 1, 'proper follows length') self.assertFalse(follows[0], 'is no longer following') def test_playlist_replace_items(self): # add tracks to playlist self.spotify.playlist_add_items( self.new_playlist['id'], self.four_tracks) playlist = self.spotify.playlist(self.new_playlist['id']) self.assertEqual(playlist['tracks']['total'], 4) self.assertEqual(len(playlist['tracks']['items']), 4) # replace with 3 other tracks self.spotify.playlist_replace_items(self.new_playlist['id'], self.other_tracks) playlist = self.spotify.playlist(self.new_playlist['id']) self.assertEqual(playlist['tracks']['total'], 3) self.assertEqual(len(playlist['tracks']['items']), 3) self.spotify.playlist_remove_all_occurrences_of_items( playlist['id'], self.other_tracks) playlist = self.spotify.playlist(self.new_playlist['id']) self.assertEqual(playlist["tracks"]["total"], 0) def test_get_playlist_by_id(self): pl = self.spotify.playlist(self.new_playlist['id']) self.assertEqual(pl["tracks"]["total"], 0) def test_max_retries_reached_post(self): import concurrent.futures max_workers = 100 total_requests = 500 def do(): self.spotify_no_retry.playlist_change_details( self.new_playlist['id'], description="test") with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: future_to_post = (executor.submit(do) for _i in range(1, total_requests)) for future in concurrent.futures.as_completed(future_to_post): try: future.result() except Exception as exc: # Test success self.assertIsInstance(exc, SpotifyException) self.assertEqual(exc.http_status, 429) return self.fail() def test_playlist_add_items(self): # add tracks to playlist self.spotify.playlist_add_items( self.new_playlist['id'], self.other_tracks) playlist = self.spotify.playlist_items(self.new_playlist['id']) self.assertEqual(playlist['total'], 3) self.assertEqual(len(playlist['items']), 3) pl = self.spotify.playlist_items(self.new_playlist['id'], limit=2) self.assertEqual(len(pl["items"]), 2) self.spotify.playlist_remove_all_occurrences_of_items( self.new_playlist['id'], self.other_tracks) playlist = self.spotify.playlist_items(self.new_playlist['id']) self.assertEqual(playlist["total"], 0) def test_playlist_add_episodes(self): # add episodes to playlist self.spotify.playlist_add_items( self.new_playlist['id'], self.four_episodes) playlist = self.spotify.playlist_items(self.new_playlist['id']) self.assertEqual(playlist['total'], 4) self.assertEqual(len(playlist['items']), 4) pl = self.spotify.playlist_items(self.new_playlist['id'], limit=2) self.assertEqual(len(pl["items"]), 2) self.spotify.playlist_remove_all_occurrences_of_items( self.new_playlist['id'], self.four_episodes) playlist = self.spotify.playlist_items(self.new_playlist['id']) self.assertEqual(playlist["total"], 0) def test_playlist_cover_image(self): # From https://dog.ceo/api/breeds/image/random small_image = "https://images.dog.ceo/breeds/poodle-toy/n02113624_8936.jpg" dog_base64 = helpers.get_as_base64(small_image) self.spotify.playlist_upload_cover_image(self.new_playlist_uri, dog_base64) res = self.spotify.playlist_cover_image(self.new_playlist_uri) self.assertEqual(len(res), 1) first_image = res[0] self.assertIn('width', first_image) self.assertIn('height', first_image) self.assertIn('url', first_image) def test_large_playlist_cover_image(self): # From https://dog.ceo/api/breeds/image/random large_image = "https://images.dog.ceo/breeds/pointer-germanlonghair/hans2.jpg" dog_base64 = helpers.get_as_base64(large_image) try: self.spotify.playlist_upload_cover_image(self.new_playlist_uri, dog_base64) except Exception as e: self.assertIsInstance(e, SpotifyException) self.assertEqual(e.http_status, 413) return self.fail() def test_deprecated_starred(self): pl = self.spotify.user_playlist(self.username) self.assertTrue(pl["tracks"] is None) self.assertTrue(pl["owner"] is None) def test_deprecated_user_playlist(self): # Test without user due to change from # https://developer.spotify.com/community/news/2018/06/12/changes-to-playlist-uris/ pl = self.spotify.user_playlist(None, self.new_playlist['id']) self.assertEqual(pl["tracks"]["total"], 0) class SpotipyLibraryApiTests(unittest.TestCase): @classmethod def setUpClass(cls): cls.four_tracks = ["spotify:track:6RtPijgfPKROxEzTHNRiDp", "spotify:track:7IHOIqZUUInxjVkko181PB", "4VrWlk8IQxevMvERoX08iC", "http://open.spotify.com/track/3cySlItpiPiIAzU3NyHCJf"] cls.album_ids = ["spotify:album:6kL09DaURb7rAoqqaA51KU", "spotify:album:6RTzC0rDbvagTSJLlY7AKl"] cls.episode_ids = [ "spotify:episode:3OEdPEYB69pfXoBrhvQYeC", "spotify:episode:5LEFdZ9pYh99wSz7Go2D0g" ] cls.username = os.getenv(CCEV['client_username']) scope = ( 'playlist-modify-public ' 'user-library-read ' 'user-follow-read ' 'user-library-modify ' 'user-read-private ' 'user-top-read ' 'user-follow-modify ' 'user-read-recently-played ' 'ugc-image-upload ' 'user-read-playback-state' ) token = prompt_for_user_token(cls.username, scope=scope) cls.spotify = Spotify(auth=token) def test_track_bad_id(self): with self.assertRaises(SpotifyException): self.spotify.track('BadID123') def test_current_user_saved_tracks(self): tracks = self.spotify.current_user_saved_tracks() self.assertGreaterEqual(len(tracks['items']), 0) def test_current_user_save_tracks(self): tracks = self.spotify.current_user_saved_tracks() total = tracks['total'] self.spotify.current_user_saved_tracks_add(self.four_tracks) tracks = self.spotify.current_user_saved_tracks() new_total = tracks['total'] self.assertEqual(new_total - total, len(self.four_tracks)) self.spotify.current_user_saved_tracks_delete( self.four_tracks) tracks = self.spotify.current_user_saved_tracks() new_total = tracks['total'] def test_current_user_unsave_tracks(self): tracks = self.spotify.current_user_saved_tracks() total = tracks['total'] self.spotify.current_user_saved_tracks_add(self.four_tracks) tracks = self.spotify.current_user_saved_tracks() new_total = tracks['total'] self.spotify.current_user_saved_tracks_delete( self.four_tracks) tracks = self.spotify.current_user_saved_tracks() new_total = tracks['total'] self.assertEqual(new_total, total) def test_current_user_saved_albums(self): # Add self.spotify.current_user_saved_albums_add(self.album_ids) albums = self.spotify.current_user_saved_albums() self.assertGreaterEqual(len(albums['items']), 2) # Contains resp = self.spotify.current_user_saved_albums_contains(self.album_ids) self.assertEqual(resp, [True, True]) # Remove self.spotify.current_user_saved_albums_delete(self.album_ids) resp = self.spotify.current_user_saved_albums_contains(self.album_ids) self.assertEqual(resp, [False, False]) def test_current_user_saved_episodes(self): # Add self.spotify.current_user_saved_episodes_add(self.episode_ids) episodes = self.spotify.current_user_saved_episodes(market="US") self.assertGreaterEqual(len(episodes['items']), 2) # Contains resp = self.spotify.current_user_saved_episodes_contains(self.episode_ids) self.assertEqual(resp, [True, True]) # Remove self.spotify.current_user_saved_episodes_delete(self.episode_ids) resp = self.spotify.current_user_saved_episodes_contains(self.episode_ids) self.assertEqual(resp, [False, False]) class SpotipyUserApiTests(unittest.TestCase): @classmethod def setUpClass(cls): cls.username = os.getenv(CCEV['client_username']) scope = ( 'playlist-modify-public ' 'user-library-read ' 'user-follow-read ' 'user-library-modify ' 'user-read-private ' 'user-top-read ' 'user-follow-modify ' 'user-read-recently-played ' 'ugc-image-upload ' 'user-read-playback-state' ) token = prompt_for_user_token(cls.username, scope=scope) cls.spotify = Spotify(auth=token) def test_basic_user_profile(self): user = self.spotify.user(self.username) self.assertEqual(user['id'], self.username.lower()) def test_current_user(self): user = self.spotify.current_user() self.assertEqual(user['id'], self.username.lower()) def test_me(self): user = self.spotify.me() self.assertTrue(user['id'] == self.username.lower()) def test_current_user_top_tracks(self): response = self.spotify.current_user_top_tracks() items = response['items'] self.assertGreaterEqual(len(items), 0) def test_current_user_top_artists(self): response = self.spotify.current_user_top_artists() items = response['items'] self.assertGreaterEqual(len(items), 0) class SpotipyBrowseApiTests(unittest.TestCase): @classmethod def setUpClass(cls): username = os.getenv(CCEV['client_username']) token = prompt_for_user_token(username) cls.spotify = Spotify(auth=token) def test_category(self): rock_cat_id = '0JQ5DAqbMKFDXXwE9BDJAr' response = self.spotify.category(rock_cat_id) self.assertEqual(response['name'], 'Rock') def test_categories(self): response = self.spotify.categories() self.assertGreater(len(response['categories']), 0) def test_categories_country(self): response = self.spotify.categories(country='US') self.assertGreater(len(response['categories']), 0) def test_categories_global(self): response = self.spotify.categories() self.assertGreater(len(response['categories']), 0) def test_categories_locale(self): response = self.spotify.categories(locale='en_US') self.assertGreater(len(response['categories']), 0) def test_categories_limit_low(self): response = self.spotify.categories(limit=1) self.assertEqual(len(response['categories']['items']), 1) def test_categories_limit_high(self): response = self.spotify.categories(limit=50) self.assertLessEqual(len(response['categories']['items']), 50) def test_category_playlists(self): response = self.spotify.categories() category = 'rock' for cat in response['categories']['items']: cat_id = cat['id'] if cat_id == category: response = self.spotify.category_playlists(category_id=cat_id) self.assertGreater(len(response['playlists']["items"]), 0) def test_category_playlists_limit_low(self): response = self.spotify.categories() category = 'rock' for cat in response['categories']['items']: cat_id = cat['id'] if cat_id == category: response = self.spotify.category_playlists(category_id=cat_id, limit=1) self.assertEqual(len(response['categories']['items']), 1) def test_category_playlists_limit_high(self): response = self.spotify.categories() category = 'rock' for cat in response['categories']['items']: cat_id = cat['id'] if cat_id == category: response = self.spotify.category_playlists(category_id=cat_id, limit=50) self.assertLessEqual(len(response['categories']['items']), 50) def test_new_releases(self): response = self.spotify.new_releases() self.assertGreater(len(response['albums']['items']), 0) def test_new_releases_limit_low(self): response = self.spotify.new_releases(limit=1) self.assertEqual(len(response['albums']['items']), 1) def test_new_releases_limit_high(self): response = self.spotify.new_releases(limit=50) self.assertLessEqual(len(response['albums']['items']), 50) def test_featured_releases(self): response = self.spotify.featured_playlists() self.assertGreater(len(response['playlists']), 0) class SpotipyFollowApiTests(unittest.TestCase): @classmethod def setUpClass(cls): cls.username = os.getenv(CCEV['client_username']) scope = ( 'playlist-modify-public ' 'user-library-read ' 'user-follow-read ' 'user-library-modify ' 'user-read-private ' 'user-top-read ' 'user-follow-modify ' 'user-read-recently-played ' 'ugc-image-upload ' 'user-read-playback-state' ) token = prompt_for_user_token(cls.username, scope=scope) cls.spotify = Spotify(auth=token) def test_current_user_follows(self): response = self.spotify.current_user_followed_artists() artists = response['artists'] self.assertGreaterEqual(len(artists['items']), 0) def test_user_follows_and_unfollows_artist(self): # Initially follows 1 artist current_user_followed_artists = self.spotify.current_user_followed_artists()[ 'artists']['total'] # Follow 2 more artists artists = ["6DPYiyq5kWVQS4RGwxzPC7", "0NbfKEOTQCcwd6o7wSDOHI"] self.spotify.user_follow_artists(artists) self.assertTrue(all(self.spotify.current_user_following_artists(artists))) # Unfollow these 2 artists self.spotify.user_unfollow_artists(artists) self.assertFalse(any(self.spotify.current_user_following_artists(artists))) res = self.spotify.current_user_followed_artists() self.assertEqual(res['artists']['total'], current_user_followed_artists) def test_user_follows_and_unfollows_user(self): users = ["11111204", "xlqeojt6n7on0j7coh9go8ifd"] # Follow 2 more users self.spotify.user_follow_users(users) self.assertTrue(all(self.spotify.current_user_following_users(users))) # Unfollow these 2 users self.spotify.user_unfollow_users(users) self.assertFalse(any(self.spotify.current_user_following_users(users))) class SpotipyPlayerApiTests(unittest.TestCase): @classmethod def setUpClass(cls): cls.username = os.getenv(CCEV['client_username']) scope = ( 'playlist-modify-public ' 'user-library-read ' 'user-follow-read ' 'user-library-modify ' 'user-read-private ' 'user-top-read ' 'user-follow-modify ' 'user-read-recently-played ' 'ugc-image-upload ' 'user-read-playback-state' ) token = prompt_for_user_token(cls.username, scope=scope) cls.spotify = Spotify(auth=token) def test_devices(self): # No devices playing by default res = self.spotify.devices() self.assertGreaterEqual(len(res["devices"]), 0) def test_current_user_recently_played(self): # No cursor res = self.spotify.current_user_recently_played() self.assertLessEqual(len(res['items']), 50) # not much more to test if account is inactive and has no recently played tracks class SpotipyImplicitGrantTests(unittest.TestCase): @classmethod def setUpClass(cls): scope = ( 'user-follow-read ' 'user-follow-modify ' ) auth_manager = SpotifyImplicitGrant(scope=scope, cache_path=".cache-implicittest") cls.spotify = Spotify(auth_manager=auth_manager) def test_current_user(self): c_user = self.spotify.current_user() user = self.spotify.user(c_user['id']) self.assertEqual(c_user['display_name'], user['display_name']) class SpotifyPKCETests(unittest.TestCase): @classmethod def setUpClass(cls): scope = ( 'user-follow-read ' 'user-follow-modify ' ) auth_manager = SpotifyPKCE(scope=scope, cache_path=".cache-pkcetest") cls.spotify = Spotify(auth_manager=auth_manager) def test_current_user(self): c_user = self.spotify.current_user() user = self.spotify.user(c_user['id']) self.assertEqual(c_user['display_name'], user['display_name']) spotipy-dev-spotipy-8a40e03/tests/unit/000077500000000000000000000000001462616510500201575ustar00rootroot00000000000000spotipy-dev-spotipy-8a40e03/tests/unit/__init__.py000066400000000000000000000000001462616510500222560ustar00rootroot00000000000000spotipy-dev-spotipy-8a40e03/tests/unit/test_oauth.py000066400000000000000000000454671462616510500227300ustar00rootroot00000000000000import io import json import unittest import unittest.mock as mock import urllib.parse as urllibparse from spotipy import SpotifyOAuth, SpotifyImplicitGrant, SpotifyPKCE from spotipy.cache_handler import MemoryCacheHandler from spotipy.oauth2 import SpotifyClientCredentials, SpotifyOauthError from spotipy.oauth2 import SpotifyStateError patch = mock.patch DEFAULT = mock.DEFAULT def _make_fake_token(expires_at, expires_in, scope): return dict( expires_at=expires_at, expires_in=expires_in, scope=scope, token_type="Bearer", refresh_token="REFRESH", access_token="ACCESS") def _fake_file(): return mock.Mock(spec_set=io.FileIO) def _token_file(token): fi = _fake_file() fi.read.return_value = token return fi def _make_oauth(*args, **kwargs): return SpotifyOAuth("CLID", "CLISEC", "REDIR", "STATE", *args, **kwargs) def _make_implicitgrantauth(*args, **kwargs): return SpotifyImplicitGrant("CLID", "REDIR", "STATE", *args, **kwargs) def _make_pkceauth(*args, **kwargs): return SpotifyPKCE("CLID", "REDIR", "STATE", *args, **kwargs) class OAuthCacheTest(unittest.TestCase): @patch.multiple(SpotifyOAuth, is_token_expired=DEFAULT, refresh_access_token=DEFAULT) @patch('spotipy.cache_handler.open', create=True) def test_gets_from_cache_path(self, opener, is_token_expired, refresh_access_token): scope = "playlist-modify-private" path = ".cache-username" tok = _make_fake_token(1, 1, scope) opener.return_value = _token_file(json.dumps(tok, ensure_ascii=False)) is_token_expired.return_value = False spot = _make_oauth(scope, path) cached_tok = spot.validate_token(spot.cache_handler.get_cached_token()) cached_tok_legacy = spot.get_cached_token() opener.assert_called_with(path) self.assertIsNotNone(cached_tok) self.assertIsNotNone(cached_tok_legacy) self.assertEqual(refresh_access_token.call_count, 0) @patch.multiple(SpotifyOAuth, is_token_expired=DEFAULT, refresh_access_token=DEFAULT) @patch('spotipy.cache_handler.open', create=True) def test_expired_token_refreshes(self, opener, is_token_expired, refresh_access_token): scope = "playlist-modify-private" path = ".cache-username" expired_tok = _make_fake_token(0, None, scope) fresh_tok = _make_fake_token(1, 1, scope) token_file = _token_file(json.dumps(expired_tok, ensure_ascii=False)) opener.return_value = token_file refresh_access_token.return_value = fresh_tok spot = _make_oauth(scope, path) spot.validate_token(spot.cache_handler.get_cached_token()) is_token_expired.assert_called_with(expired_tok) refresh_access_token.assert_called_with(expired_tok['refresh_token']) opener.assert_any_call(path) @patch.multiple(SpotifyOAuth, is_token_expired=DEFAULT, refresh_access_token=DEFAULT) @patch('spotipy.cache_handler.open', create=True) def test_badly_scoped_token_bails(self, opener, is_token_expired, refresh_access_token): token_scope = "playlist-modify-public" requested_scope = "playlist-modify-private" path = ".cache-username" tok = _make_fake_token(1, 1, token_scope) opener.return_value = _token_file(json.dumps(tok, ensure_ascii=False)) is_token_expired.return_value = False spot = _make_oauth(requested_scope, path) cached_tok = spot.validate_token(spot.cache_handler.get_cached_token()) opener.assert_called_with(path) self.assertIsNone(cached_tok) self.assertEqual(refresh_access_token.call_count, 0) @patch('spotipy.cache_handler.open', create=True) def test_saves_to_cache_path(self, opener): scope = "playlist-modify-private" path = ".cache-username" tok = _make_fake_token(1, 1, scope) fi = _fake_file() opener.return_value = fi spot = SpotifyOAuth("CLID", "CLISEC", "REDIR", "STATE", scope, path) spot.cache_handler.save_token_to_cache(tok) opener.assert_called_with(path, 'w') self.assertTrue(fi.write.called) @patch('spotipy.cache_handler.open', create=True) def test_saves_to_cache_path_legacy(self, opener): scope = "playlist-modify-private" path = ".cache-username" tok = _make_fake_token(1, 1, scope) fi = _fake_file() opener.return_value = fi spot = SpotifyOAuth("CLID", "CLISEC", "REDIR", "STATE", scope, path) spot._save_token_info(tok) opener.assert_called_with(path, 'w') self.assertTrue(fi.write.called) def test_cache_handler(self): scope = "playlist-modify-private" tok = _make_fake_token(1, 1, scope) spot = _make_oauth(scope, cache_handler=MemoryCacheHandler()) spot.cache_handler.save_token_to_cache(tok) cached_tok = spot.cache_handler.get_cached_token() self.assertEqual(tok, cached_tok) class TestSpotifyOAuthGetAuthorizeUrl(unittest.TestCase): def test_get_authorize_url_doesnt_pass_state_by_default(self): oauth = SpotifyOAuth("CLID", "CLISEC", "REDIR") url = oauth.get_authorize_url() parsed_url = urllibparse.urlparse(url) parsed_qs = urllibparse.parse_qs(parsed_url.query) self.assertNotIn('state', parsed_qs) def test_get_authorize_url_passes_state_from_constructor(self): state = "STATE" oauth = SpotifyOAuth("CLID", "CLISEC", "REDIR", state) url = oauth.get_authorize_url() parsed_url = urllibparse.urlparse(url) parsed_qs = urllibparse.parse_qs(parsed_url.query) self.assertEqual(parsed_qs['state'][0], state) def test_get_authorize_url_passes_state_from_func_call(self): state = "STATE" oauth = SpotifyOAuth("CLID", "CLISEC", "REDIR", "NOT STATE") url = oauth.get_authorize_url(state=state) parsed_url = urllibparse.urlparse(url) parsed_qs = urllibparse.parse_qs(parsed_url.query) self.assertEqual(parsed_qs['state'][0], state) def test_get_authorize_url_does_not_show_dialog_by_default(self): oauth = SpotifyOAuth("CLID", "CLISEC", "REDIR") url = oauth.get_authorize_url() parsed_url = urllibparse.urlparse(url) parsed_qs = urllibparse.parse_qs(parsed_url.query) self.assertNotIn('show_dialog', parsed_qs) def test_get_authorize_url_shows_dialog_when_requested(self): oauth = SpotifyOAuth("CLID", "CLISEC", "REDIR", show_dialog=True) url = oauth.get_authorize_url() parsed_url = urllibparse.urlparse(url) parsed_qs = urllibparse.parse_qs(parsed_url.query) self.assertTrue(parsed_qs['show_dialog']) class TestSpotifyOAuthGetAuthResponseInteractive(unittest.TestCase): @patch('spotipy.oauth2.webbrowser') @patch( 'spotipy.oauth2.SpotifyOAuth._get_user_input', return_value="redir.io?code=abcde" ) def test_get_auth_response_without_state(self, webbrowser_mock, get_user_input_mock): oauth = SpotifyOAuth("CLID", "CLISEC", "redir.io") code = oauth.get_auth_response() self.assertEqual(code, "abcde") @patch('spotipy.oauth2.webbrowser') @patch( 'spotipy.oauth2.SpotifyOAuth._get_user_input', return_value="redir.io?code=abcde&state=wxyz" ) def test_get_auth_response_with_consistent_state(self, webbrowser_mock, get_user_input_mock): oauth = SpotifyOAuth("CLID", "CLISEC", "redir.io", state='wxyz') code = oauth.get_auth_response() self.assertEqual(code, "abcde") @patch('spotipy.oauth2.webbrowser') @patch( 'spotipy.oauth2.SpotifyOAuth._get_user_input', return_value="redir.io?code=abcde&state=someotherstate" ) def test_get_auth_response_with_inconsistent_state(self, webbrowser_mock, get_user_input_mock): oauth = SpotifyOAuth("CLID", "CLISEC", "redir.io", state='wxyz') with self.assertRaises(SpotifyStateError): oauth.get_auth_response() class TestSpotifyClientCredentials(unittest.TestCase): def test_spotify_client_credentials_get_access_token(self): oauth = SpotifyClientCredentials(client_id='ID', client_secret='SECRET') with self.assertRaises(SpotifyOauthError) as error: oauth.get_access_token(check_cache=False) self.assertEqual(error.exception.error, 'invalid_client') class ImplicitGrantCacheTest(unittest.TestCase): @patch.object(SpotifyImplicitGrant, "is_token_expired", DEFAULT) @patch('spotipy.cache_handler.open', create=True) def test_gets_from_cache_path(self, opener, is_token_expired): scope = "playlist-modify-private" path = ".cache-username" tok = _make_fake_token(1, 1, scope) opener.return_value = _token_file(json.dumps(tok, ensure_ascii=False)) is_token_expired.return_value = False spot = _make_implicitgrantauth(scope, path) cached_tok = spot.cache_handler.get_cached_token() cached_tok_legacy = spot.get_cached_token() opener.assert_called_with(path) self.assertIsNotNone(cached_tok) self.assertIsNotNone(cached_tok_legacy) @patch.object(SpotifyImplicitGrant, "is_token_expired", DEFAULT) @patch('spotipy.cache_handler.open', create=True) def test_expired_token_returns_none(self, opener, is_token_expired): scope = "playlist-modify-private" path = ".cache-username" expired_tok = _make_fake_token(0, None, scope) token_file = _token_file(json.dumps(expired_tok, ensure_ascii=False)) opener.return_value = token_file spot = _make_implicitgrantauth(scope, path) cached_tok = spot.validate_token(spot.cache_handler.get_cached_token()) is_token_expired.assert_called_with(expired_tok) opener.assert_any_call(path) self.assertIsNone(cached_tok) @patch.object(SpotifyImplicitGrant, "is_token_expired", DEFAULT) @patch('spotipy.cache_handler.open', create=True) def test_badly_scoped_token_bails(self, opener, is_token_expired): token_scope = "playlist-modify-public" requested_scope = "playlist-modify-private" path = ".cache-username" tok = _make_fake_token(1, 1, token_scope) opener.return_value = _token_file(json.dumps(tok, ensure_ascii=False)) is_token_expired.return_value = False spot = _make_implicitgrantauth(requested_scope, path) cached_tok = spot.validate_token(spot.cache_handler.get_cached_token()) opener.assert_called_with(path) self.assertIsNone(cached_tok) @patch('spotipy.cache_handler.open', create=True) def test_saves_to_cache_path(self, opener): scope = "playlist-modify-private" path = ".cache-username" tok = _make_fake_token(1, 1, scope) fi = _fake_file() opener.return_value = fi spot = SpotifyImplicitGrant("CLID", "REDIR", "STATE", scope, path) spot.cache_handler.save_token_to_cache(tok) opener.assert_called_with(path, 'w') self.assertTrue(fi.write.called) @patch('spotipy.cache_handler.open', create=True) def test_saves_to_cache_path_legacy(self, opener): scope = "playlist-modify-private" path = ".cache-username" tok = _make_fake_token(1, 1, scope) fi = _fake_file() opener.return_value = fi spot = SpotifyImplicitGrant("CLID", "REDIR", "STATE", scope, path) spot._save_token_info(tok) opener.assert_called_with(path, 'w') self.assertTrue(fi.write.called) class TestSpotifyImplicitGrant(unittest.TestCase): def test_get_authorize_url_doesnt_pass_state_by_default(self): auth = SpotifyImplicitGrant("CLID", "REDIR") url = auth.get_authorize_url() parsed_url = urllibparse.urlparse(url) parsed_qs = urllibparse.parse_qs(parsed_url.query) self.assertNotIn('state', parsed_qs) def test_get_authorize_url_passes_state_from_constructor(self): state = "STATE" auth = SpotifyImplicitGrant("CLID", "REDIR", state) url = auth.get_authorize_url() parsed_url = urllibparse.urlparse(url) parsed_qs = urllibparse.parse_qs(parsed_url.query) self.assertEqual(parsed_qs['state'][0], state) def test_get_authorize_url_passes_state_from_func_call(self): state = "STATE" auth = SpotifyImplicitGrant("CLID", "REDIR", "NOT STATE") url = auth.get_authorize_url(state=state) parsed_url = urllibparse.urlparse(url) parsed_qs = urllibparse.parse_qs(parsed_url.query) self.assertEqual(parsed_qs['state'][0], state) def test_get_authorize_url_does_not_show_dialog_by_default(self): auth = SpotifyImplicitGrant("CLID", "REDIR") url = auth.get_authorize_url() parsed_url = urllibparse.urlparse(url) parsed_qs = urllibparse.parse_qs(parsed_url.query) self.assertNotIn('show_dialog', parsed_qs) def test_get_authorize_url_shows_dialog_when_requested(self): auth = SpotifyImplicitGrant("CLID", "REDIR", show_dialog=True) url = auth.get_authorize_url() parsed_url = urllibparse.urlparse(url) parsed_qs = urllibparse.parse_qs(parsed_url.query) self.assertTrue(parsed_qs['show_dialog']) class SpotifyPKCECacheTest(unittest.TestCase): @patch.multiple(SpotifyPKCE, is_token_expired=DEFAULT, refresh_access_token=DEFAULT) @patch('spotipy.cache_handler.open', create=True) def test_gets_from_cache_path(self, opener, is_token_expired, refresh_access_token): scope = "playlist-modify-private" path = ".cache-username" tok = _make_fake_token(1, 1, scope) opener.return_value = _token_file(json.dumps(tok, ensure_ascii=False)) is_token_expired.return_value = False spot = _make_pkceauth(scope, path) cached_tok = spot.cache_handler.get_cached_token() cached_tok_legacy = spot.get_cached_token() opener.assert_called_with(path) self.assertIsNotNone(cached_tok) self.assertIsNotNone(cached_tok_legacy) self.assertEqual(refresh_access_token.call_count, 0) @patch.multiple(SpotifyPKCE, is_token_expired=DEFAULT, refresh_access_token=DEFAULT) @patch('spotipy.cache_handler.open', create=True) def test_expired_token_refreshes(self, opener, is_token_expired, refresh_access_token): scope = "playlist-modify-private" path = ".cache-username" expired_tok = _make_fake_token(0, None, scope) fresh_tok = _make_fake_token(1, 1, scope) token_file = _token_file(json.dumps(expired_tok, ensure_ascii=False)) opener.return_value = token_file refresh_access_token.return_value = fresh_tok spot = _make_pkceauth(scope, path) spot.validate_token(spot.cache_handler.get_cached_token()) is_token_expired.assert_called_with(expired_tok) refresh_access_token.assert_called_with(expired_tok['refresh_token']) opener.assert_any_call(path) @patch.multiple(SpotifyPKCE, is_token_expired=DEFAULT, refresh_access_token=DEFAULT) @patch('spotipy.cache_handler.open', create=True) def test_badly_scoped_token_bails(self, opener, is_token_expired, refresh_access_token): token_scope = "playlist-modify-public" requested_scope = "playlist-modify-private" path = ".cache-username" tok = _make_fake_token(1, 1, token_scope) opener.return_value = _token_file(json.dumps(tok, ensure_ascii=False)) is_token_expired.return_value = False spot = _make_pkceauth(requested_scope, path) cached_tok = spot.validate_token(spot.cache_handler.get_cached_token()) opener.assert_called_with(path) self.assertIsNone(cached_tok) self.assertEqual(refresh_access_token.call_count, 0) @patch('spotipy.cache_handler.open', create=True) def test_saves_to_cache_path(self, opener): scope = "playlist-modify-private" path = ".cache-username" tok = _make_fake_token(1, 1, scope) fi = _fake_file() opener.return_value = fi spot = SpotifyPKCE("CLID", "REDIR", "STATE", scope, path) spot.cache_handler.save_token_to_cache(tok) opener.assert_called_with(path, 'w') self.assertTrue(fi.write.called) @patch('spotipy.cache_handler.open', create=True) def test_saves_to_cache_path_legacy(self, opener): scope = "playlist-modify-private" path = ".cache-username" tok = _make_fake_token(1, 1, scope) fi = _fake_file() opener.return_value = fi spot = SpotifyPKCE("CLID", "REDIR", "STATE", scope, path) spot._save_token_info(tok) opener.assert_called_with(path, 'w') self.assertTrue(fi.write.called) class TestSpotifyPKCE(unittest.TestCase): def test_generate_code_verifier_for_pkce(self): auth = SpotifyPKCE("CLID", "REDIR") auth.get_pkce_handshake_parameters() self.assertTrue(auth.code_verifier) def test_generate_code_challenge_for_pkce(self): auth = SpotifyPKCE("CLID", "REDIR") auth.get_pkce_handshake_parameters() self.assertTrue(auth.code_challenge) def test_code_verifier_and_code_challenge_are_correct(self): import hashlib import base64 auth = SpotifyPKCE("CLID", "REDIR") auth.get_pkce_handshake_parameters() self.assertEqual(auth.code_challenge, base64.urlsafe_b64encode( hashlib.sha256(auth.code_verifier.encode('utf-8')) .digest()) .decode('utf-8') .replace('=', '')) def test_get_authorize_url_doesnt_pass_state_by_default(self): auth = SpotifyPKCE("CLID", "REDIR") url = auth.get_authorize_url() parsed_url = urllibparse.urlparse(url) parsed_qs = urllibparse.parse_qs(parsed_url.query) self.assertNotIn('state', parsed_qs) def test_get_authorize_url_passes_state_from_constructor(self): state = "STATE" auth = SpotifyPKCE("CLID", "REDIR", state) url = auth.get_authorize_url() parsed_url = urllibparse.urlparse(url) parsed_qs = urllibparse.parse_qs(parsed_url.query) self.assertEqual(parsed_qs['state'][0], state) def test_get_authorize_url_passes_state_from_func_call(self): state = "STATE" auth = SpotifyPKCE("CLID", "REDIR") url = auth.get_authorize_url(state=state) parsed_url = urllibparse.urlparse(url) parsed_qs = urllibparse.parse_qs(parsed_url.query) self.assertEqual(parsed_qs['state'][0], state) spotipy-dev-spotipy-8a40e03/tox.ini000066400000000000000000000002761462616510500173560ustar00rootroot00000000000000[tox] envlist = py3{8,9,10,11,12} [testenv] deps= requests commands=python -m unittest discover -v tests [flake8] max-line-length = 99 exclude= .git, dist, docs, examples