pax_global_header00006660000000000000000000000064147600122010014503gustar00rootroot0000000000000052 comment=103d6873facdf7a4943437c8f903c2f6a9c2deb7 spotipy-2.25.1/000077500000000000000000000000001476001220100133015ustar00rootroot00000000000000spotipy-2.25.1/.github/000077500000000000000000000000001476001220100146415ustar00rootroot00000000000000spotipy-2.25.1/.github/ISSUE_TEMPLATE/000077500000000000000000000000001476001220100170245ustar00rootroot00000000000000spotipy-2.25.1/.github/ISSUE_TEMPLATE/bug_report.md000066400000000000000000000012661476001220100215230ustar00rootroot00000000000000--- 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-2.25.1/.github/ISSUE_TEMPLATE/feature_request.md000066400000000000000000000011251476001220100225500ustar00rootroot00000000000000--- 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-2.25.1/.github/ISSUE_TEMPLATE/help.md000066400000000000000000000005441476001220100203010ustar00rootroot00000000000000--- name: Help about: I have a question title: '' labels: question assignees: '' --- spotipy-2.25.1/.github/SECURITY.md000066400000000000000000000006541476001220100164370ustar00rootroot00000000000000# 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-2.25.1/.github/dependabot.yml000066400000000000000000000007661476001220100175020ustar00rootroot00000000000000# 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-2.25.1/.github/workflows/000077500000000000000000000000001476001220100166765ustar00rootroot00000000000000spotipy-2.25.1/.github/workflows/integration_tests.yml000066400000000000000000000014321476001220100231660ustar00rootroot00000000000000name: Integration tests on: [push, pull_request] jobs: build: runs-on: ubuntu-latest env: SPOTIPY_CLIENT_ID: ${{ secrets.SPOTIPY_CLIENT_ID }} SPOTIPY_CLIENT_SECRET: ${{ secrets.SPOTIPY_CLIENT_SECRET }} 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 . - name: Run non user endpoints integration tests run: | python -m unittest discover -v tests/integration/non_user_endpoints spotipy-2.25.1/.github/workflows/lint.yml000066400000000000000000000011211476001220100203620ustar00rootroot00000000000000name: Lint on: [push, pull_request] jobs: build: runs-on: ubuntu-20.04 steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.x" # Lint can be done on latest Python only - name: Install dependencies run: | python -m pip install --upgrade pip pip install .[test] - name: Check pep8 with flake8 run: | flake8 . --count --show-source --statistics - name: Check sorted imports with isort run: | isort . -c spotipy-2.25.1/.github/workflows/publish.yml000066400000000000000000000016101476001220100210650ustar00rootroot00000000000000name: 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-2.25.1/.github/workflows/pull_request.yml000066400000000000000000000006571476001220100221550ustar00rootroot00000000000000name: "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-2.25.1/.github/workflows/unit_tests.yml000066400000000000000000000011311476001220100216160ustar00rootroot00000000000000name: Unit 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 . - name: Run unit tests run: | python -m unittest discover -v tests/unit spotipy-2.25.1/.gitignore000066400000000000000000000010741476001220100152730ustar00rootroot00000000000000# 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-2.25.1/.gitmodules000066400000000000000000000001371476001220100154570ustar00rootroot00000000000000[submodule "examples"] path = examples url = git@github.com:spotipy-dev/spotipy-examples.git spotipy-2.25.1/.readthedocs.yaml000066400000000000000000000004531476001220100165320ustar00rootroot00000000000000# 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-2.25.1/CHANGELOG.md000066400000000000000000000465761476001220100151340ustar00rootroot00000000000000# 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.25.1] - 2025-02-27 ### Added - Added examples for audiobooks, shows and episodes methods to examples directory ### Fixed - Fixed scripts in examples directory that didn't run correctly - Updated documentation for `Client.current_user_top_artists` to indicate maximum number of artists limit - Set auth cache file permissions to `600`: https://github.com/spotipy-dev/spotipy/security/advisories/GHSA-pwhh-q4h6-w599 ### Changed - Updated get_cached_token and save_token_to_cache methods to utilize Python's Context Management Protocol - Added except clause to get_cached_token method to handle json decode errors - Added warnings and updated docs due to Spotify's deprecation of HTTP and "localhost" redirect URIs - Use newer string formatters () - Marked `recommendation_genre_seeds` as deprecated ## [2.25.0] - 2025-03-01 ### Added - Added unit tests for queue functions - Added detailed function docstrings to 'util.py', including descriptions and special sections that lists arguments, returns, and raises. - Updated order of instructions for Python and pip package manager installation in TUTORIAL.md - Updated TUTORIAL.md instructions to match current layout of Spotify Developer Dashboard - Added test_artist_id, test_artist_url, and test_artists_mixed_ids to non_user_endpoints test.py - Added rate/request limit to FAQ - Added custom `urllib3.Retry` class for printing a warning when a rate/request limit is reached. - Added `personalized_playlist.py`, `track_recommendations.py`, and `audio_features_analysis.py` to `/examples`. - Discord badge in README - Added `SpotifyBaseException` and moved all exceptions to `exceptions.py` - Marked the following methods as deprecated: - artist_related_artists - recommendations - audio_features - audio_analysis - featured_playlists - category_playlists - Added FAQ entry for inaccessible playlists - Workflow to check for f-strings ### Changed - Split test and lint workflows - Updated get_cached_token and save_token_to_cache methods to utilize Python's Context Management Protocol - Added except clause to get_cached_token method to handle json decode errors ### Fixed - Audiobook integration tests - Edited docstrings for certain functions in client.py for functions that are no longer in use and have been replaced. - `current_user_unfollow_playlist()` now supports playlist IDs, URLs, and URIs rather than previously where it only supported playlist IDs. ### Removed - `mock` no longer listed as a test dependency. Only built-in `unittest.mock` is actually used. ## [2.24.0] - 2024-05-30 ### Added - Added `MemcacheCacheHandler`, a cache handler that stores the token info using pymemcache. - Added support for audiobook endpoints: `get_audiobook`, `get_audiobooks`, and `get_audiobook_chapters`. - Added integration tests for audiobook endpoints. - Added `update` field to `current_user_follow_playlist`. ### Changed - Updated get_cached_token and save_token_to_cache methods to utilize Python's Context Management Protocol - Added except clause to get_cached_token method to handle json decode errors - 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-2.25.1/CODE_OF_CONDUCT.md000066400000000000000000000057051476001220100161070ustar00rootroot00000000000000# 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-2.25.1/CONTRIBUTING.md000066400000000000000000000044611476001220100155370ustar00rootroot00000000000000## 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://127.0.0.1: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://127.0.0.1:8080" ``` ### Branch Overview After restarting development on version 3, we decided to restrict commits to certain branches in order to push the development forward. To give you a flavour of what we mean, here are some examples of what PRs go where: **v3**: - any kind of refactoring - better documentation - enhancements - code styles **master (v2)**: - bug fixes - deprecations - new endpoints (until we release v3) - basic functionality Just choose v3 if you are unsure which branch to work on. ### 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 pip install .[test] To automatically fix some of the code style: autopep8 --in-place --aggressive --recursive . To verify the code style: flake8 . To make sure if the import lists are stored correctly: isort . -c Sort them automatically with: isort . ### 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-2.25.1/FAQ.md000066400000000000000000000067001476001220100142350ustar00rootroot00000000000000## 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](https://github.com/spotipy-dev/spotipy-examples/tree/main/apps/flask_api) ### 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](https://github.com/spotipy-dev/spotipy-examples/blob/main/scripts/headless.py). ### My application is not responding This is still speculation, but it seems that Spotify has two limits. A rate limit and a request limit. - The rate limit prevents a script from requesting too much from the API in a short period of time. - The request limit limits how many requests you can make in a 24 hour window. The limits appear to be endpoint-specific, so each endpoint has its own limits. If your application stops responding, it's likely that you've reached the request limit. There's nothing Spotipy can do to prevent this, but you can follow Spotify's [Rate Limits](https://developer.spotify.com/documentation/web-api/concepts/rate-limits) guide to learn how rate limiting works and what you can do to avoid ever hitting a limit. #### *Why* is the application not responding? Spotipy (or more precisely `urllib3`) has a backoff-retry strategy built in, which is waiting until the rate limit is gone. If you want to receive an error instead, then you can pass `retries=0` to `Spotify` like this: ```python sp = spotipy.Spotify( retries=0, ... ) ``` The error raised is a `spotipy.exceptions.SpotifyException` ### I get a 404 when trying to access a Spotify-owned playlist Spotify has begun restricting access to algorithmic and Spotify-owned editorial playlists. Only applications with an existing extended mode will still have access to these playlists. Read more about this change here: [Introducing some changes to our Web API](https://developer.spotify.com/blog/2024-11-27-changes-to-the-web-api) spotipy-2.25.1/LICENSE.md000066400000000000000000000020541476001220100147060ustar00rootroot00000000000000MIT 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-2.25.1/MANIFEST.in000066400000000000000000000000601476001220100150330ustar00rootroot00000000000000include *.txt *.md recursive-include docs *.txt spotipy-2.25.1/README.md000066400000000000000000000077371476001220100145760ustar00rootroot00000000000000# 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. ![Integration tests](https://github.com/spotipy-dev/spotipy/actions/workflows/integration_tests.yml/badge.svg?branch=master) [![Documentation Status](https://readthedocs.org/projects/spotipy/badge/?version=master)](https://spotipy.readthedocs.io/en/latest/?badge=master) [![Discord server](https://img.shields.io/discord/1244611850700849183?style=flat&logo=discord&logoColor=7289DA&color=7289DA)](https://discord.gg/HP6xcPsTPJ) ## Table of Contents - [Features](#features) - [Installation](#installation) - [Quick Start](#quick-start) - [Reporting Issues](#reporting-issues) - [Contributing](#contributing) ## Features Spotipy supports all of the features of the Spotify Web API including access to all end points, and support for user authorization. For details on the capabilities you are encouraged to review the [Spotify Web API](https://developer.spotify.com/web-api/) documentation. ## Installation ```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: ### 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-2.25.1/TUTORIAL.md000066400000000000000000000155321476001220100150740ustar00rootroot00000000000000# 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. 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/ **2. 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/ A. After ensuring that pip is installed, run the following command in Terminal to install Spotipy: pip install spotipy --upgrade **3. Experience with Basic Linux Commands** 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, begin by clicking on your profile name at the top right of your screen and then click “Dashboard†to go to Spotify’s Developer Dashboard. B. Check the box "Accept the Spotify Developer Terms of Service" and then click "Accept the terms". On the next page, verify your email address if you haven't already. Click the "Create an App" button. Enter any name and description you'd like for your new app. Next, add "http://127.0.0.1:1234" (or any other port number of your choosing) to the "Redirect URI" secction. Check the box "I understand and agree with Spotify's Developer Terms of Service and Design Guidelines" and then click the "Save" button. C. Click on "Settings". Underneath "Client ID", you'll see a "View Client Secret" link. Click the link to reveal your Client secret and copy both your Client secret and your Client ID somewhere so that you can access them later. ## Step 2. Installation and Setup A. Create a folder somewhere on your computer where you'd like to store the code for your Spotipy app. You can create a folder in terminal with this command: ```mkdir folder_name``` B. In your new folder, create a Python file named main.py. You can create the file directly from Terminal using a built in text editor like Vim, which comes preinstalled on Linux operating systems. To create the file with Vim, ensure that you are in your new directory, then run: vim main.py 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")) ``` D. Replace YOUR_APP_CLIENT_ID and YOUR_APP_CLIENT_SECRET with the values you copied and saved in step 1D. Replace YOUR_APP_REDIRECT_URI with the URI you set in step 1B. ## Step 3. Start Using Spotipy After completing steps 1 and 2, your app is fully configured and ready to fetch data from the Spotify API. All that's left is to tell the API what data we're looking for, and we do that by adding some additional code to main.py. The code that follows is just an example - once you get it working, you should feel free to modify it in order to get different results. For now, let's assume that we want to print the names of all 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. Run the command: ``` pip install spotipy ``` After the package is installed, run the app again. spotipy-2.25.1/docs/000077500000000000000000000000001476001220100142315ustar00rootroot00000000000000spotipy-2.25.1/docs/Makefile000066400000000000000000000127001476001220100156710ustar00rootroot00000000000000# 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-2.25.1/docs/conf.py000066400000000000000000000173661476001220100155450ustar00rootroot00000000000000# # 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 os import sys # 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-2.25.1/docs/images/000077500000000000000000000000001476001220100154765ustar00rootroot00000000000000spotipy-2.25.1/docs/images/spotify-web-api-doc.jpg000066400000000000000000001221121476001220100217610ustar00rootroot00000000000000ÿØÿà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Óî̸CeÆ@˪ëÖ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ğÀ‰X3#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\ÎHCJd!_<ÌiÌœ\ö÷ÿ0zyíN¾Ö³{¶ì™«×5-?[­ *­Oìöı¶vÕßœ|õS\º«‡˜#î}XgÌËtü”§&­ßbrœ6$Yqğø‰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½(ÄpZpìÙØÙ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Æ÷Ğfz¥>ŸÒ>/&¯kökÑáéêÿÚÖ KÕ>wè]wŸO‹@Y H¡3­f«-‘£óbÒåÜ•Œ/]¬×à°^$i¨,1áÜpŸõa{_°ÿ!µî÷÷ÇMu6³­éú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)K0iTƒrÈşªZôléhÛ­íVaJÂ20îÜÖ0Ö‘Z®èˆÀ(c-¶ç;AµeİÓ  „÷=¤K »Ğ릳5_À™Vb].‹¶ésYYRµ  ó…Ø´ãdiP,3Ê{@5l© æİ:ÇİX.– aè~ƒ™åRì´ia>($Eğ[M°İÓÚı sH̼‰d¥®[\±µP/:³ÖÖ²¨%±Ì=z¥åV4°ÕU5k ³HâÑ¡…¡‚µ¬Hş ¬Ãe£~ͤÃaĞ-A²®Ä†´´©¶Æ”-Ôݧi¶[tµÍÔİL1ûúİN—O¨ş_ÏxcAV×éÑ ìµ Âïä8+[š{Øz¨s%"Ã%“è«AÙP•'6¹Åİ]7¼s!Î"~ {ß¡€Íİ}ınKcjY®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ä"€Ã;ùŸ6ftZZh†: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Ñ47=¤2ª/ÁøŞ/ĞûNwmRó(ºİŞïæÃ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³¢É†L1UTGƒÍô´è™p>qZÓ$Û§FZëÿLÁÉ>†ûó¾KÌù¯5æ°u5ô<îè‡KÂøFî¾6P¤êî{®Ä/Çrõ0DbÊêÙ¥”4 ZÆ—ÎN§ÙæoI–6GJP-p ×ïÛ©_YúVo2|_‡—Ñ£»E†¹Üî§Ö¿ZÌ|/„ão³õ?2Ä }¯cêDÿÅ›öP!#KknK’׉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"ÛØõ>…õ/ñ —Mc!ÆÜ—ó&„&–ĬÜë‹Ç˜ §]†—õ½©ë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õª¡Ly<=&×}<ø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»èÑesó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?EM×õ(Ò¬Ü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›Ò„¬0cR¡ •ˆ|¨õÊ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}nN—¾¯†Ë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¿ç⺠†ì÷òro­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¬.œ^×hyo‹·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)7G#’>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¤´åÆ'ü>×·©Y15Œ'¨º{Ç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>SG%“Á‡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ø”ş%?Šwx§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ù•ò BQ¦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ü”ô”ÃYlVéÀÃ¥$3ë4õÊú·êGUed+«umATÚ+êt(¬¯«U}C•µ­3²¾G1©(ÁWÅc;ÈX$!Òİ©\…[½Äm°U†B®Œ6Ûe__¢yı›Êm $ø¬yï:1_ wYŸEDq<ô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ÑJw=ÅË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ÁÀwUÔ ª#½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ÇÈüEuRC‚Ø›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ÒЃ¦ØĞôÅôCYSܱÌ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å26ø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ñç]Óœci/<æé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ì4ÇŒyó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¿î™>õ×é¿èNjᆘÀ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\>åØüÅ1u2®1r”Æş Ş`µæŒE-¬W¼¬ãŞ)İLwQö73áRauñ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ÅwUøƒ½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¢±†1ME»»—ß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=ˆZcz÷¶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÷·'¼yxHö¬Éúƒı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æ¦LDCÌ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÷ıKS :Ô±²^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ªZp‡x æ œœKT}ᜈËñch±R=v—­âvËÌhr-èÒRs³ë¤<+Û´ ղ桪3)Ì6‘o^`â8BãˆËª=öP¾…™Ğ`‰˜ˆmé™…Ff_IŠ=BÚº‰x5ï©Nе¤í=åjßN Ì1Åñ bL‘ » ÄE\ƒ×7ù:kı}†„Ä<Űne9`…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(ê/´ȱ‡ys9û‚Ô?ˆøâ Ö`:Ã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 ±ïpyPkÕ‘"ƨÅĸ¬ºÁ¬jˆñ……'gÇPÑ~¦%µñül½¥–µª÷–hQÚEЯ@™Ÿ%ç ®®İØ[AŸi’jg”Ax”-Ñ ;÷™òø›óï¿+´P¿_ö"OyôÿÂ];h¸I‹Ì AB®¾ı/ñ =\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]¦rW%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ìÿf3dzÔ¿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 Ä Tv–åÍ,¶ á.¨»;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ø¨Æryx5©„[Á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ê9xN3Ã0¬S~a…d¿ÜV– 3 Ü¹1M" ø†Z§õ&bPq2A_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„‘Ys(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èℊT6¬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ó7sÚ|ê‡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«QB¬"âʲÿ³å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 €Ö@ ¦+ÃlfÂä 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+¢º>·1F¬¡¬×ñ|>?!X¬Ökj¹#¶o¡È€),±"Å;›b2X—%ÊáÒÄd*á¿3[dïd–-Öà;UL_üg«këıeh¨(H&„ß½kÖÉ€fÌÑğW޹+ÏÈÕò)Ç… ÉVjøexWP¬Ö$».ì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[%’ȬÑVZVQ«eoÎy=ÔÎ÷¾ºè©ZÖ(N¶ñ¹öxº|Ö\´ê 8‡Ç.*±Ğ‚"Å ì–ôaO¡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¥)ÑBZ¶¯ß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éSpº„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”hF§ì_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‡x3 óç!Ò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­jkzZR¶ª#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%JZXû©Ë,Á­‹{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”•+Zk\é-ßè×”¼Ì̾|•(ÒÕå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Ú÷µ›ËÖÜò82•?êùß¿±Á÷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Ğü_-xÇŒñú¿›ÿ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Î~É´ÆKP¯´È@ØØï#¸=™è Ê‘Âá¨Я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àğTM4ŵ‡à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-2.25.1/docs/index.rst000066400000000000000000000322471476001220100161020ustar00rootroot00000000000000.. 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`` or ``http://127.0.0.1:9090``. .. note:: If you choose an `http`-scheme URL, and it's for `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(f"{i + 1 + playlists['offset']:4d} {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 `spotipy-examples repository `_ 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 ========== 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://127.0.0.1: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-2.25.1/docs/make.bat000066400000000000000000000117521476001220100156440ustar00rootroot00000000000000@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-2.25.1/docs/requirements.txt000066400000000000000000000000631476001220100175140ustar00rootroot00000000000000Sphinx~=8.1.3 sphinx-rtd-theme~=3.0.2 redis>=3.5.3 spotipy-2.25.1/examples/000077500000000000000000000000001476001220100151175ustar00rootroot00000000000000spotipy-2.25.1/setup.py000066400000000000000000000017461476001220100150230ustar00rootroot00000000000000from setuptools import setup with open("README.md") as f: long_description = f.read() memcache_cache_reqs = [ 'pymemcache>=3.5.2' ] extra_reqs = { 'memcache': [ 'pymemcache>=3.5.2' ], 'test': [ 'autopep8>=2.3.2', 'flake8>=7.1.1', 'flake8-string-format>=0.3.0', 'isort>=5.13.2' ] } setup( name='spotipy', version='2.25.1', 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" ], extras_require=extra_reqs, license='MIT', packages=['spotipy']) spotipy-2.25.1/spotipy/000077500000000000000000000000001476001220100150105ustar00rootroot00000000000000spotipy-2.25.1/spotipy/__init__.py000066400000000000000000000002371476001220100171230ustar00rootroot00000000000000from .cache_handler import * # noqa from .client import * # noqa from .exceptions import * # noqa from .oauth2 import * # noqa from .util import * # noqa spotipy-2.25.1/spotipy/cache_handler.py000066400000000000000000000170531476001220100201300ustar00rootroot00000000000000__all__ = [ 'CacheHandler', 'CacheFileHandler', 'DjangoSessionCacheHandler', 'FlaskSessionCacheHandler', 'MemoryCacheHandler', 'RedisCacheHandler', 'MemcacheCacheHandler'] import errno import json import logging import os from redis import RedisError from spotipy.util import CLIENT_CREDS_ENV_VARS 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() 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: with open(self.cache_path, encoding='utf-8') as f: token_info_string = f.read() token_info = json.loads(token_info_string) except OSError as error: if error.errno == errno.ENOENT: logger.debug(f"cache does not exist at: {self.cache_path}") else: logger.warning(f"Couldn't read cache at: {self.cache_path}") except json.JSONDecodeError: logger.warning(f"Couldn't decode JSON from cache at: {self.cache_path}") return token_info def save_token_to_cache(self, token_info): try: with open(self.cache_path, "w", encoding='utf-8') as f: f.write(json.dumps(token_info, cls=self.encoder_cls)) # https://github.com/spotipy-dev/spotipy/security/advisories/GHSA-pwhh-q4h6-w599 os.chmod(self.cache_path, 0o600) except OSError: logger.warning(f"Couldn't write token to cache at: {self.cache_path}") except FileNotFoundError: logger.warning(f"Couldn't set permissions to cache file at: {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(f"Error saving token to cache: {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(f"Error saving token to cache: {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(f"Error getting token from cache: {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(f"Error saving token to cache: {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(f"Error getting token to cache: {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(f"Error saving token to cache: {e}") spotipy-2.25.1/spotipy/client.py000066400000000000000000002340511476001220100166450ustar00rootroot00000000000000""" A simple and thin Python library for the Spotify Web API """ __all__ = ["Spotify", "SpotifyException"] import json import logging import re import warnings from collections import defaultdict import requests from spotipy.exceptions import SpotifyException from spotipy.util import Retry 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 = 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(f"Sending {method} to {url} with Params: " f"{args.get('params')} Headers: {headers} and Body: {args.get('data')!r}") 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(f"HTTP Error for {method} to {url} with Params: " f"{args.get('params')} returned {response.status_code} due to {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(f'RESULTS: {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 """ warnings.warn( "You're using `artist_related_artists(...)`, " "which is marked as deprecated by Spotify.", DeprecationWarning ) 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, ): """ This function is no longer in use, please use the recommended function in the warning! Changes a playlist's name and/or public/private state Parameters: - user - the id of the user - playlist_id - the id of the playlist - name - optional name of the playlist - public - optional is the playlist public - collaborative - optional is the playlist collaborative - description - optional description of the playlist """ warnings.warn( "You should use `playlist_change_details(playlist_id, ...)` instead", DeprecationWarning, ) return self.playlist_change_details(playlist_id, name, public, collaborative, description) def user_playlist_unfollow(self, user, playlist_id): """ This function is no longer in use, please use the recommended function in the warning! Unfollows (deletes) a playlist for a user Parameters: - user - the id of the user - name - the name of the playlist """ warnings.warn( "You should use `current_user_unfollow_playlist(playlist_id)` instead", DeprecationWarning, ) return self.current_user_unfollow_playlist(playlist_id) def user_playlist_add_tracks( self, user, playlist_id, tracks, position=None ): """ This function is no longer in use, please use the recommended function in the warning! Adds tracks to a playlist Parameters: - user - the id of the user - playlist_id - the id of the playlist - tracks - a list of track URIs, URLs or IDs - position - the position to add the tracks """ warnings.warn( "You should use `playlist_add_items(playlist_id, tracks)` instead", DeprecationWarning, ) tracks = [self._get_uri("track", tid) for tid in tracks] return self.playlist_add_items(playlist_id, tracks, position) def user_playlist_add_episodes( self, user, playlist_id, episodes, position=None ): """ This function is no longer in use, please use the recommended function in the warning! Adds episodes to a playlist Parameters: - user - the id of the user - playlist_id - the id of the playlist - episodes - a list of track URIs, URLs or IDs - position - the position to add the episodes """ warnings.warn( "You should use `playlist_add_items(playlist_id, episodes)` instead", DeprecationWarning, ) episodes = [self._get_uri("episode", tid) for tid in episodes] return self.playlist_add_items(playlist_id, episodes, position) def user_playlist_replace_tracks(self, user, playlist_id, tracks): """ This function is no longer in use, please use the recommended function in the warning! Replace all tracks in a playlist for a user Parameters: - user - the id of the user - playlist_id - the id of the playlist - tracks - the list of track ids to add to the playlist """ warnings.warn( "You should use `playlist_replace_items(playlist_id, tracks)` instead", DeprecationWarning, ) return self.playlist_replace_items(playlist_id, tracks) def user_playlist_reorder_tracks( self, user, playlist_id, range_start, insert_before, range_length=1, snapshot_id=None, ): """ This function is no longer in use, please use the recommended function in the warning! Reorder tracks in a playlist from a user Parameters: - user - the id of the user - playlist_id - the id of the playlist - range_start - the position of the first track to be reordered - range_length - optional the number of tracks to be reordered (default: 1) - insert_before - the position where the tracks should be inserted - snapshot_id - optional playlist's snapshot ID """ warnings.warn( "You should use `playlist_reorder_items(playlist_id, ...)` instead", DeprecationWarning, ) return self.playlist_reorder_items(playlist_id, range_start, insert_before, range_length, snapshot_id) def user_playlist_remove_all_occurrences_of_tracks( self, user, playlist_id, tracks, snapshot_id=None ): """ This function is no longer in use, please use the recommended function in the warning! Removes all occurrences of the given tracks from the given playlist Parameters: - user - the id of the user - playlist_id - the id of the playlist - tracks - the list of track ids to remove from the playlist - snapshot_id - optional id of the playlist snapshot """ warnings.warn( "You should use `playlist_remove_all_occurrences_of_items" "(playlist_id, tracks)` instead", DeprecationWarning, ) return self.playlist_remove_all_occurrences_of_items(playlist_id, tracks, snapshot_id) def user_playlist_remove_specific_occurrences_of_tracks( self, user, playlist_id, tracks, snapshot_id=None ): """ This function is no longer in use, please use the recommended function in the warning! Removes all occurrences of the given tracks from the given playlist Parameters: - user - the id of the user - playlist_id - the id of the playlist - tracks - an array of objects containing Spotify URIs of the tracks to remove with their current positions in the playlist. For example: [ { "uri":"4iV5W9uYEdYUVa79Axb7Rh", "positions":[2] }, { "uri":"1301WleyT98MSxVHPZCA6M", "positions":[7] } ] - snapshot_id - optional id of the playlist snapshot """ warnings.warn( "You should use `playlist_remove_specific_occurrences_of_items" "(playlist_id, tracks)` instead", DeprecationWarning, ) plid = self._get_id("playlist", playlist_id) ftracks = [] for tr in tracks: ftracks.append( { "uri": self._get_uri("track", tr["uri"]), "positions": tr["positions"], } ) payload = {"tracks": ftracks} if snapshot_id: payload["snapshot_id"] = snapshot_id return self._delete( f"users/{user}/playlists/{plid}/tracks", payload=payload ) def user_playlist_follow_playlist(self, playlist_owner_id, playlist_id): """ This function is no longer in use, please use the recommended function in the warning! Add the current authenticated user as a follower of a playlist. Parameters: - playlist_owner_id - the user id of the playlist owner - playlist_id - the id of the playlist """ warnings.warn( "You should use `current_user_follow_playlist(playlist_id)` instead", DeprecationWarning, ) return self.current_user_follow_playlist(playlist_id) def user_playlist_is_following( self, playlist_owner_id, playlist_id, user_ids ): """ This function is no longer in use, please use the recommended function in the warning! Check to see if the given users are following the given playlist Parameters: - playlist_owner_id - the user id of the playlist owner - playlist_id - the id of the playlist - user_ids - the ids of the users that you want to check to see if they follow the playlist. Maximum: 5 ids. """ warnings.warn( "You should use `playlist_is_following(playlist_id, user_ids)` instead", DeprecationWarning, ) return self.playlist_is_following(playlist_id, user_ids) def playlist_change_details( self, 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: - playlist_id - the id of the playlist """ return self._delete( f"playlists/{self._get_id('playlist', 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 = f"playlists/{playlist_id}/followers/contains?ids={','.join(user_ids)}" return self._get(endpoint) 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 (max 50) - 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. """ warnings.warn( "You're using `featured_playlists(...)`, " "which is marked as deprecated by Spotify.", DeprecationWarning, ) 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. """ warnings.warn( "You're using `category_playlists(...)`, " "which is marked as deprecated by Spotify.", DeprecationWarning, ) 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. """ warnings.warn( "You're using `recommendations(...)`, " "which is marked as deprecated by Spotify.", DeprecationWarning, ) 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): warnings.warn( "You're using `recommendation_genre_seeds(...)`, " "which is marked as deprecated by Spotify.", DeprecationWarning, ) """ 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 """ warnings.warn( "You're using `audio_analysis(...)`, " "which is marked as deprecated by Spotify.", DeprecationWarning, ) 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 """ warnings.warn( "You're using `audio_features(...)`, " "which is marked as deprecated by Spotify.", DeprecationWarning, ) 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(f"limit was auto-adjusted to equal {total} " f"as it must not be higher than 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-2.25.1/spotipy/exceptions.py000066400000000000000000000030551476001220100175460ustar00rootroot00000000000000class SpotifyBaseException(Exception): pass class SpotifyException(SpotifyBaseException): 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 (f"http status: {self.http_status}, " f"code: {self.code} - {self.msg}, " f"reason: {self.reason}") class SpotifyOauthError(SpotifyBaseException): """ 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) spotipy-2.25.1/spotipy/oauth2.py000066400000000000000000001472271476001220100166010ustar00rootroot00000000000000__all__ = [ "SpotifyClientCredentials", "SpotifyOAuth", "SpotifyOauthError", "SpotifyStateError", "SpotifyImplicitGrant", "SpotifyPKCE" ] import base64 import logging import os import time import urllib.parse as urllibparse import warnings import webbrowser from http.server import BaseHTTPRequestHandler, HTTPServer from urllib.parse import parse_qsl, urlparse import requests from spotipy.cache_handler import CacheFileHandler, CacheHandler from spotipy.exceptions import SpotifyOauthError, SpotifyStateError from spotipy.util import CLIENT_CREDS_ENV_VARS, get_host_port, normalize_scope logger = logging.getLogger(__name__) 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(f"Sending POST request to {self.OAUTH_TOKEN_URL} with Headers: " f"{headers} and Body: {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(f"Opened {auth_url} in your browser") except webbrowser.Error: logger.error(f"Please navigate here: {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 = ( f"Go to the following URL: {url}\n" "Enter the URL you were redirected to: " ) 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 redirect_host == 'localhost': logger.warning( "Using 'localhost' as a redirect URI is being deprecated. " "Use a loopback IP address such as 127.0.0.1 " "to ensure your app remains functional.") if redirect_info.scheme == "http" and redirect_host not in ("127.0.0.1", "localhost"): logger.warning( "Redirect URIs using HTTP are being deprecated. " "To ensure your app remains functional, use HTTPS instead.") 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(f'Using `{redirect_host}` as redirect URI without a port. ' f'Specify a port (e.g. `{redirect_host}:8080`) to allow ' 'automatic retrieval of authentication code ' 'instead of having to copy and paste ' 'the URL your browser is redirected to.') 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(f"Sending POST request to {self.OAUTH_TOKEN_URL} with Headers: " f"{headers} and Body: {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(f"Sending POST request to {self.OAUTH_TOKEN_URL} with Headers: " f"{headers} and Body: {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(f"Opened {auth_url} in your browser") except webbrowser.Error: logger.error(f"Please navigate here: {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 redirect_host == 'localhost': logger.warning( "Using 'localhost' as a redirect URI is being deprecated. " "Use a loopback IP address such as 127.0.0.1 " "to ensure your app remains functional.") if redirect_info.scheme == "http" and redirect_host not in ("127.0.0.1", "localhost"): logger.warning( "Redirect URIs using HTTP are being deprecated. " "To ensure your app remains functional, use HTTPS instead.") 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(f'Using `{redirect_host}` as redirect URI without a port. ' f'Specify a port (e.g. `{redirect_host}:8080`) to allow ' 'automatic retrieval of authentication code ' 'instead of having to copy and paste ' 'the URL your browser is redirected to.') 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 = (f"Go to the following URL: {url}\n" f"Enter the URL you were redirected to: ") 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(f"Sending POST request to {self.OAUTH_TOKEN_URL} with Headers: " f"{headers} and Body: {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(f"Sending POST request to {self.OAUTH_TOKEN_URL} with Headers: " f"{headers} and Body: {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("Spotify is deprecating 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(f"Opened {auth_url} in your browser") except webbrowser.Error: logger.error(f"Please navigate here: {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(f"""

Authentication status: {status}

This window can be closed. """) 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-2.25.1/spotipy/util.py000066400000000000000000000131621476001220100163420ustar00rootroot00000000000000from __future__ import annotations """ Shows a user's playlists. This needs to be authenticated via OAuth. """ __all__ = ["CLIENT_CREDS_ENV_VARS", "prompt_for_user_token"] import logging import os import warnings from types import TracebackType import urllib3 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 ) """Prompt the user to login if necessary and returns a user token suitable for use with the spotipy.Spotify constructor. Parameters: - username - the Spotify username. (optional) - scope - the desired scope of the request. (optional) - client_id - the client ID of your app. (required) - client_secret - the client secret of your app. (required) - redirect_uri - the redirect URI of your app. (required) - cache_path - path to location to save tokens. (required) - oauth_manager - OAuth manager object. (optional) - show_dialog - If True, a login prompt always shows or defaults to False. (optional) """ if not oauth_manager: if not client_id: client_id = os.getenv("SPOTIPY_CLIENT_ID") if not client_secret: client_secret = os.getenv("SPOTIPY_CLIENT_SECRET") if not redirect_uri: redirect_uri = os.getenv("SPOTIPY_REDIRECT_URI") if not client_id: LOGGER.warning( """ You need to set your Spotify API credentials. You can do this by setting environment variables like so: export SPOTIPY_CLIENT_ID='your-spotify-client-id' export SPOTIPY_CLIENT_SECRET='your-spotify-client-secret' export SPOTIPY_REDIRECT_URI='your-app-redirect-url' Get your credentials at https://developer.spotify.com/my-applications """ ) raise spotipy.SpotifyException(550, -1, "no credentials set") sp_oauth = oauth_manager or spotipy.SpotifyOAuth( client_id, client_secret, redirect_uri, scope=scope, cache_path=cache_path, username=username, show_dialog=show_dialog ) # try to get a valid token for this user, from the cache, # if not in the cache, then create a new (this will send # the user to a web page where they can authorize this app) token_info = sp_oauth.validate_token(sp_oauth.cache_handler.get_cached_token()) if not token_info: code = sp_oauth.get_auth_response() token = sp_oauth.get_access_token(code, as_dict=False) else: return token_info["access_token"] # Auth'ed API request if token: return token else: return None def get_host_port(netloc): """ Split the network location string into host and port and returns a tuple where the host is a string and the the port is an integer. Parameters: - netloc - a string representing the network location. """ if ":" in netloc: host, port = netloc.split(":", 1) port = int(port) else: host = netloc port = None return host, port def normalize_scope(scope): """Normalize the scope to verify that it is a list or tuple. A string input will split the string by commas to create a list of scopes. A list or tuple input is used directly. Parameters: - scope - a string representing scopes separated by commas, or a list/tuple of scopes. """ if scope: if isinstance(scope, str): scopes = scope.split(',') 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 class Retry(urllib3.Retry): """ Custom class for printing a warning when a rate/request limit is reached. """ def increment( self, method: str | None = None, url: str | None = None, response: urllib3.BaseHTTPResponse | None = None, error: Exception | None = None, _pool: urllib3.connectionpool.ConnectionPool | None = None, _stacktrace: TracebackType | None = None, ) -> urllib3.Retry: if response: retry_header = response.headers.get("Retry-After") if self.is_retry(method, response.status, bool(retry_header)): logging.warning("Your application has reached a rate/request limit. " f"Retry will occur after: {retry_header}") return super().increment(method, url, response=response, error=error, _pool=_pool, _stacktrace=_stacktrace) spotipy-2.25.1/tests/000077500000000000000000000000001476001220100144435ustar00rootroot00000000000000spotipy-2.25.1/tests/__init__.py000066400000000000000000000000001476001220100165420ustar00rootroot00000000000000spotipy-2.25.1/tests/helpers.py000066400000000000000000000006671476001220100164700ustar00rootroot00000000000000import 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-2.25.1/tests/integration/000077500000000000000000000000001476001220100167665ustar00rootroot00000000000000spotipy-2.25.1/tests/integration/__init__.py000066400000000000000000000000001476001220100210650ustar00rootroot00000000000000spotipy-2.25.1/tests/integration/non_user_endpoints/000077500000000000000000000000001476001220100227015ustar00rootroot00000000000000spotipy-2.25.1/tests/integration/non_user_endpoints/__init__.py000066400000000000000000000000001476001220100250000ustar00rootroot00000000000000spotipy-2.25.1/tests/integration/non_user_endpoints/test.py000066400000000000000000000507031476001220100242370ustar00rootroot00000000000000import unittest import requests import spotipy from spotipy import Spotify, SpotifyClientCredentials, SpotifyException 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' radiohead_id = "4Z8W4fKeB5YxbusRsdQVPb" radiohead_url = "https://open.spotify.com/artist/4Z8W4fKeB5YxbusRsdQVPb" qotsa_url = "https://open.spotify.com/artist/4pejUc4iciQfgdX6OKulQn" angeles_haydn_urn = 'spotify:album:1vAbqAeuJVWNAe7UR00bdM' heavyweight_urn = 'spotify:show:5c26B28vZMN8PG0Nppmn5G' heavyweight_id = '5c26B28vZMN8PG0Nppmn5G' 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' dune_urn = 'spotify:audiobook:7iHfbu1YPACw6oZPAFJtqe' dune_id = '7iHfbu1YPACw6oZPAFJtqe' dune_url = 'https://open.spotify.com/audiobook/7iHfbu1YPACw6oZPAFJtqe' two_books = [ 'spotify:audiobook:7iHfbu1YPACw6oZPAFJtqe', 'spotify:audiobook:67VtmjZitn25TWocsyAEyh'] @classmethod def setUpClass(self): self.spotify = Spotify( client_credentials_manager=SpotifyClientCredentials()) self.spotify.trace = False def test_artist_urn(self): artist = self.spotify.artist(self.radiohead_urn) self.assertTrue(artist['name'] == 'Radiohead') def test_artist_url(self): artist = self.spotify.artist(self.radiohead_url) self.assertTrue(artist['name'] == 'Radiohead') def test_artist_id(self): artist = self.spotify.artist(self.radiohead_id) self.assertTrue(artist['name'] == 'Radiohead') def test_artists(self): results = self.spotify.artists([self.weezer_urn, self.radiohead_urn]) self.assertTrue('artists' in results) self.assertTrue(len(results['artists']) == 2) def test_artists_mixed_ids(self): results = self.spotify.artists([self.weezer_urn, self.radiohead_id, self.qotsa_url]) self.assertTrue('artists' in results) self.assertTrue(len(results['artists']) == 3) def test_album_urn(self): album = self.spotify.album(self.pinkerton_urn) self.assertTrue(album['name'] == 'Pinkerton') 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_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 'Weezer' in album['name']: # Weezer has many albums containing Weezer 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.dune_urn, market="US") self.assertTrue(audiobook['name'] == 'Dune: Book One in the Dune Chronicles') def test_get_audiobook_bad_urn(self): with self.assertRaises(SpotifyException): self.spotify.get_audiobook("bogus_urn", market="US") def test_get_audiobooks(self): results = self.spotify.get_audiobooks(self.two_books, market="US") self.assertTrue('audiobooks' in results) self.assertTrue(len(results['audiobooks']) == 2) self.assertTrue(results['audiobooks'][0]['name'] == 'Dune: Book One in the Dune Chronicles') self.assertTrue(results['audiobooks'][1]['name'] == 'The Helper') def test_get_audiobook_chapters(self): results = self.spotify.get_audiobook_chapters( self.dune_urn, market="US", limit=10, offset=5) self.assertTrue('items' in results) self.assertTrue(len(results['items']) == 10) self.assertTrue(results['items'][0]['chapter_number'] == 5) self.assertTrue(results['items'][9]['chapter_number'] == 14) spotipy-2.25.1/tests/integration/user_endpoints/000077500000000000000000000000001476001220100220275ustar00rootroot00000000000000spotipy-2.25.1/tests/integration/user_endpoints/__init__.py000066400000000000000000000000001476001220100241260ustar00rootroot00000000000000spotipy-2.25.1/tests/integration/user_endpoints/test.py000066400000000000000000000514741476001220100233730ustar00rootroot00000000000000import os import unittest from spotipy import CLIENT_CREDS_ENV_VARS as CCEV from spotipy import (Spotify, SpotifyException, SpotifyImplicitGrant, SpotifyPKCE, prompt_for_user_token) 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_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) 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']) class SpotifyQueueApiTests(unittest.TestCase): @classmethod def setUp(self): self.spotify = Spotify(auth="test_token") def test_get_queue(self, mock_get): # Mock the response from _get mock_get.return_value = {'songs': ['song1', 'song2']} # Call the queue function response = self.spotify.queue() # Check if the correct endpoint is called mock_get.assert_called_with("me/player/queue") # Check if the response is as expected self.assertEqual(response, {'songs': ['song1', 'song2']}) def test_add_to_queue(self, mock_post): test_uri = 'spotify:track:123' # Call the add_to_queue function self.spotify.add_to_queue(test_uri) # Check if the correct endpoint is called endpoint = f"me/player/queue?uri={test_uri}" mock_post.assert_called_with(endpoint) def test_add_to_queue_with_device_id(self, mock_post): test_uri = 'spotify:track:123' device_id = 'device123' # Call the add_to_queue function with a device_id self.spotify.add_to_queue(test_uri, device_id=device_id) # Check if the correct endpoint is called endpoint = f"me/player/queue?uri={test_uri}&device_id={device_id}" mock_post.assert_called_with(endpoint) spotipy-2.25.1/tests/unit/000077500000000000000000000000001476001220100154225ustar00rootroot00000000000000spotipy-2.25.1/tests/unit/__init__.py000066400000000000000000000000001476001220100175210ustar00rootroot00000000000000spotipy-2.25.1/tests/unit/test_oauth.py000066400000000000000000000527441476001220100201670ustar00rootroot00000000000000import io import json import unittest import unittest.mock as mock import urllib.parse as urllibparse from spotipy import SpotifyImplicitGrant, SpotifyOAuth, SpotifyPKCE from spotipy.cache_handler import MemoryCacheHandler from spotipy.oauth2 import (SpotifyClientCredentials, SpotifyOauthError, 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): """Test that the token is retrieved from the cache path.""" scope = "playlist-modify-private" path = ".cache-username" tok = _make_fake_token(1, 1, scope) token_file = _token_file(json.dumps(tok, ensure_ascii=False)) opener.return_value = token_file opener.return_value.__enter__ = mock.Mock(return_value=token_file) opener.return_value.__exit__ = mock.Mock(return_value=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, encoding='utf-8') 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): """Test that an expired token is refreshed.""" 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.__enter__ = mock.Mock(return_value=token_file) opener.return_value.__exit__ = mock.Mock(return_value=False) 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, encoding='utf-8') @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) token_file = _token_file(json.dumps(tok, ensure_ascii=False)) opener.return_value = token_file opener.return_value.__enter__ = mock.Mock(return_value=token_file) opener.return_value.__exit__ = mock.Mock(return_value=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, encoding='utf-8') 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): """Test that the token is saved to the cache path.""" scope = "playlist-modify-private" path = ".cache-username" tok = _make_fake_token(1, 1, scope) fi = _fake_file() opener.return_value = fi opener.return_value.__enter__ = mock.Mock(return_value=fi) opener.return_value.__exit__ = mock.Mock(return_value=False) spot = SpotifyOAuth("CLID", "CLISEC", "REDIR", "STATE", scope, path) spot.cache_handler.save_token_to_cache(tok) opener.assert_called_with(path, 'w', encoding='utf-8') 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 opener.return_value.__enter__ = mock.Mock(return_value=fi) opener.return_value.__exit__ = mock.Mock(return_value=False) spot = SpotifyOAuth("CLID", "CLISEC", "REDIR", "STATE", scope, path) spot._save_token_info(tok) opener.assert_called_with(path, 'w', encoding='utf-8') 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) token_file = _token_file(json.dumps(tok, ensure_ascii=False)) opener.return_value = token_file opener.return_value.__enter__ = mock.Mock(return_value=token_file) opener.return_value.__exit__ = mock.Mock(return_value=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, encoding='utf-8') 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): """Test that an expired token returns None.""" 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 opener.return_value.__enter__ = mock.Mock(return_value=token_file) opener.return_value.__exit__ = mock.Mock(return_value=False) 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, encoding='utf-8') 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) token_file = _token_file(json.dumps(tok, ensure_ascii=False)) opener.return_value = token_file opener.return_value.__enter__ = mock.Mock(return_value=token_file) opener.return_value.__exit__ = mock.Mock(return_value=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, encoding='utf-8') 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 opener.return_value.__enter__ = mock.Mock(return_value=fi) opener.return_value.__exit__ = mock.Mock(return_value=False) spot = SpotifyImplicitGrant("CLID", "REDIR", "STATE", scope, path) spot.cache_handler.save_token_to_cache(tok) opener.assert_called_with(path, 'w', encoding='utf-8') 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 opener.return_value.__enter__ = mock.Mock(return_value=fi) opener.return_value.__exit__ = mock.Mock(return_value=False) spot = SpotifyImplicitGrant("CLID", "REDIR", "STATE", scope, path) spot._save_token_info(tok) opener.assert_called_with(path, 'w', encoding='utf-8') 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) token_file = _token_file(json.dumps(tok, ensure_ascii=False)) opener.return_value = token_file opener.return_value.__enter__ = mock.Mock(return_value=token_file) opener.return_value.__exit__ = mock.Mock(return_value=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, encoding='utf-8') 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.__enter__ = mock.Mock(return_value=token_file) opener.return_value.__exit__ = mock.Mock(return_value=False) 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, encoding='utf-8') @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) token_file = _token_file(json.dumps(tok, ensure_ascii=False)) opener.return_value = token_file opener.return_value.__enter__ = mock.Mock(return_value=token_file) opener.return_value.__exit__ = mock.Mock(return_value=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, encoding='utf-8') 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 opener.return_value.__enter__ = mock.Mock(return_value=fi) opener.return_value.__exit__ = mock.Mock(return_value=False) spot = SpotifyPKCE("CLID", "REDIR", "STATE", scope, path) spot.cache_handler.save_token_to_cache(tok) opener.assert_called_with(path, 'w', encoding='utf-8') 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 opener.return_value.__enter__ = mock.Mock(return_value=fi) opener.return_value.__exit__ = mock.Mock(return_value=False) spot = SpotifyPKCE("CLID", "REDIR", "STATE", scope, path) spot._save_token_info(tok) opener.assert_called_with(path, 'w', encoding='utf-8') 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 base64 import hashlib 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-2.25.1/tox.ini000066400000000000000000000003031476001220100146100ustar00rootroot00000000000000[tox] envlist = py3{8,9,10,11,12} [testenv] deps= requests commands=python -m unittest discover -v tests/unit [flake8] max-line-length = 99 exclude= .git, dist, docs, examples