├── .github └── dependabot.yml ├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.rst ├── requirements.txt ├── setup.py └── soundcloud ├── __init__.py ├── client.py ├── hashconversions.py ├── request.py ├── resource.py └── tests ├── __init__.py ├── test_client.py ├── test_encoding.py ├── test_oauth.py ├── test_requests.py ├── test_resource.py └── utils.py /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | commit-message: 8 | prefix: chore 9 | include: scope 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg-info -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | - "3.3" 5 | - "3.4" 6 | - "3.5" 7 | - "pypy" 8 | script: nosetests --with-doctest 9 | sudo: false 10 | cache: pip 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012, SoundCloud Ltd., Paul Osman 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are 6 | met: 7 | 8 | Redistributions of source code must retain the above copyright notice, 9 | this list of conditions and the following disclaimer. Redistributions 10 | in binary form must reproduce the above copyright notice, this list of 11 | conditions and the following disclaimer in the documentation and/or 12 | other materials provided with the distribution. THIS SOFTWARE IS 13 | PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY 14 | EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 15 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 16 | PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 17 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 18 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 19 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 20 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 21 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 22 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 23 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include soundcloud *.py 2 | include distribute_setup.py 3 | include LICENSE 4 | include *.txt 5 | include *.rst 6 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ===================================== 2 | ⚠️⚠️DEPRECATED - NO LONGER MAINTAINED⚠️⚠️ 3 | ===================================== 4 | This repository is no longer maintained by the SoundCloud team due to capacity constraints. We're instead focusing our efforts on improving the API & the developer platform. Please note, at the time of updating this, the repo is already not in sync with the latest API changes. 5 | 6 | We recommend the community to fork this repo in order to maintain the SDK. We'd be more than happy to make a reference on our developer that the developers can use different SDKs build by the community. In case you need to reach out to us, please head over to https://github.com/soundcloud/api/issues 7 | 8 | ================= 9 | soundcloud-python 10 | ================= 11 | 12 | .. image:: https://travis-ci.org/soundcloud/soundcloud-python.svg 13 | :target: https://travis-ci.org/soundcloud/soundcloud-python 14 | 15 | A friendly wrapper around the `Soundcloud API`_. 16 | 17 | .. _Soundcloud API: http://developers.soundcloud.com/ 18 | 19 | Installation 20 | ------------ 21 | 22 | To install soundcloud-python, simply: :: 23 | 24 | pip install soundcloud 25 | 26 | Or if you're not hip to the pip: :: 27 | 28 | easy_install soundcloud 29 | 30 | Basic Use 31 | --------- 32 | 33 | To use soundcloud-python, you must first create a `Client` instance, 34 | passing at a minimum the client id you obtained when you `registered 35 | your app`_: :: 36 | 37 | import soundcloud 38 | 39 | client = soundcloud.Client(client_id=YOUR_CLIENT_ID) 40 | 41 | The client instance can then be used to fetch or modify resources: :: 42 | 43 | tracks = client.get('/tracks', limit=10) 44 | for track in tracks.collection: 45 | print track.title 46 | app = client.get('/apps/124') 47 | print app.permalink_url 48 | 49 | .. _registered your app: http://soundcloud.com/you/apps/ 50 | 51 | Authentication 52 | -------------- 53 | 54 | All `OAuth2 authorization flows`_ supported by the Soundcloud API are 55 | available in soundcloud-python. If you only need read-only access to 56 | public resources, simply provide a client id when creating a `Client` 57 | instance: :: 58 | 59 | import soundcloud 60 | 61 | client = soundcloud.Client(client_id=YOUR_CLIENT_ID) 62 | track = client.get('/tracks/30709985') 63 | print track.title 64 | 65 | If however, you need to access private resources or modify a resource, 66 | you will need to have a user delegate access to your application. To do 67 | this, you can use one of the following OAuth2 authorization flows. 68 | 69 | **Authorization Code Flow** 70 | 71 | The `Authorization Code Flow`_ involves redirecting the user to soundcloud.com 72 | where they will log in and grant access to your application: :: 73 | 74 | import soundcloud 75 | 76 | client = soundcloud.Client( 77 | client_id=YOUR_CLIENT_ID, 78 | client_secret=YOUR_CLIENT_SECRET, 79 | redirect_uri='http://yourapp.com/callback' 80 | ) 81 | redirect(client.authorize_url()) 82 | 83 | Note that `redirect_uri` must match the value you provided when you 84 | registered your application. After granting access, the user will be 85 | redirected to this uri, at which point your application can exchange 86 | the returned code for an access token: :: 87 | 88 | access_token, expires, scope, refresh_token = client.exchange_token( 89 | code=request.args.get('code')) 90 | render_text("Hi There, %s" % client.get('/me').username) 91 | 92 | 93 | **User Credentials Flow** 94 | 95 | The `User Credentials Flow`_ allows you to exchange a username and 96 | password for an access token. Be cautious about using this flow, it's 97 | not very kind to ask your users for their password, but may be 98 | necessary in some use cases: :: 99 | 100 | import soundcloud 101 | 102 | client = soundcloud.Client( 103 | client_id=YOUR_CLIENT_ID, 104 | client_secret=YOUR_CLIENT_SECRET, 105 | username='jane@example.com', 106 | password='janespassword' 107 | ) 108 | print client.get('/me').username 109 | 110 | .. _`OAuth2 authorization flows`: http://developers.soundcloud.com/docs/api/authentication 111 | .. _`Authorization Code Flow`: http://developers.soundcloud.com/docs/api/authentication#user-agent-flow 112 | .. _`User Credentials Flow`: http://developers.soundcloud.com/docs/api/authentication#user-credentials-flow 113 | 114 | Examples 115 | -------- 116 | 117 | Resolve a track and print its id: :: 118 | 119 | import soundcloud 120 | 121 | client = soundcloud.Client(client_id=YOUR_CLIENT_ID) 122 | 123 | track = client.get('/resolve', url='http://soundcloud.com/forss/flickermood') 124 | 125 | print track.id 126 | 127 | Upload a track: :: 128 | 129 | import soundcloud 130 | 131 | client = soundcloud.Client(access_token="a valid access token") 132 | 133 | track = client.post('/tracks', track={ 134 | 'title': 'This is a sample track', 135 | 'sharing': 'private', 136 | 'asset_data': open('mytrack.mp4', 'rb') 137 | }) 138 | 139 | print track.title 140 | 141 | Start following a user: :: 142 | 143 | import soundcloud 144 | 145 | client = soundcloud.Client(access_token="a valid access token") 146 | user_id_to_follow = 123 147 | client.put('/me/followings/%d' % user_id_to_follow) 148 | 149 | Update your profile description: :: 150 | 151 | import soundcloud 152 | 153 | client = soundcloud.Client(access_token="a valid access token") 154 | client.put('/me', user={ 155 | 'description': "a new description" 156 | }) 157 | 158 | Proxy Support 159 | ------------- 160 | 161 | If you're behind a proxy, you can specify it when creating a client: :: 162 | 163 | import soundcloud 164 | 165 | proxies = { 166 | 'http': 'example.com:8000' 167 | } 168 | client = soundcloud.Client(access_token="a valid access token", 169 | proxies=proxies) 170 | 171 | The proxies kwarg is a dictionary with protocols as keys and host:port as values. 172 | 173 | Redirects 174 | --------- 175 | 176 | By default, 301 or 302 redirects will be followed for idempotent methods. There are certain cases where you may want to disable this, for example: :: 177 | 178 | import soundcloud 179 | 180 | client = soundcloud.Client(access_token="a valid access token") 181 | track = client.get('/tracks/293/stream', allow_redirects=False) 182 | print track.location 183 | 184 | Will print a tracks streaming URL. If ``allow_redirects`` was omitted, a binary stream would be returned instead. 185 | 186 | Running Tests 187 | ------------- 188 | 189 | To run the tests, run: :: 190 | 191 | $ pip install -r requirements.txt 192 | $ nosetests --with-doctest 193 | .................. 194 | 195 | Success! 196 | 197 | Contributing 198 | ------------ 199 | 200 | Contributions are awesome. You are most welcome to `submit issues`_, 201 | or `fork the repository`_. 202 | 203 | soundcloud-python is published under a `BSD License`_. 204 | 205 | .. _`submit issues`: https://github.com/soundcloud/soundcloud-python/issues 206 | .. _`fork the repository`: https://github.com/soundcloud/soundcloud-python 207 | .. _`BSD License`: https://github.com/soundcloud/soundcloud-python/blob/master/README 208 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | nose>=1.1.2 2 | fudge==1.0.3 3 | requests>=1.0.0 4 | simplejson>=2.0 5 | six>=1.2.0 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from setuptools import setup 4 | 5 | 6 | version = None 7 | for line in open('./soundcloud/__init__.py'): 8 | m = re.search('__version__\s*=\s*(.*)', line) 9 | if m: 10 | version = m.group(1).strip()[1:-1] # quotes 11 | break 12 | assert version 13 | 14 | setup( 15 | name='soundcloud', 16 | version=version, 17 | description='A friendly wrapper library for the Soundcloud API', 18 | author='SoundCloud', 19 | author_email='api@soundcloud.com', 20 | url='https://github.com/soundcloud/soundcloud-python', 21 | license='BSD', 22 | packages=['soundcloud'], 23 | include_package_data=True, 24 | package_data={ 25 | '': ['README.rst'] 26 | }, 27 | install_requires=[ 28 | 'fudge>=1.0.3', 29 | 'requests>=0.14.0', 30 | 'simplejson>=2.0', 31 | ], 32 | tests_require=[ 33 | 'nose>=1.1.2', 34 | ], 35 | classifiers=[ 36 | 'Development Status :: 4 - Beta', 37 | 'Intended Audience :: Developers', 38 | 'License :: OSI Approved :: BSD License', 39 | 'Operating System :: OS Independent', 40 | 'Programming Language :: Python', 41 | 'Topic :: Internet', 42 | 'Topic :: Multimedia :: Sound/Audio', 43 | 'Topic :: Software Development :: Libraries :: Python Modules', 44 | ] 45 | ) 46 | -------------------------------------------------------------------------------- /soundcloud/__init__.py: -------------------------------------------------------------------------------- 1 | """Python Soundcloud API Wrapper.""" 2 | 3 | __version__ = '0.5.0' 4 | __all__ = ['Client'] 5 | 6 | USER_AGENT = 'SoundCloud Python API Wrapper %s' % __version__ 7 | 8 | from soundcloud.client import Client 9 | -------------------------------------------------------------------------------- /soundcloud/client.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | try: 3 | from urllib import urlencode 4 | except ImportError: 5 | from urllib.parse import urlencode 6 | 7 | from soundcloud.resource import wrapped_resource 8 | from soundcloud.request import make_request 9 | 10 | 11 | class Client(object): 12 | """A client for interacting with Soundcloud resources.""" 13 | 14 | use_ssl = True 15 | host = 'api.soundcloud.com' 16 | 17 | def __init__(self, **kwargs): 18 | """Create a client instance with the provided options. Options should 19 | be passed in as kwargs. 20 | """ 21 | self.use_ssl = kwargs.get('use_ssl', self.use_ssl) 22 | self.host = kwargs.get('host', self.host) 23 | self.scheme = self.use_ssl and 'https://' or 'http://' 24 | self.options = kwargs 25 | self._authorize_url = None 26 | 27 | self.client_id = kwargs.get('client_id') 28 | 29 | if 'access_token' in kwargs: 30 | self.access_token = kwargs.get('access_token') 31 | return 32 | 33 | if 'client_id' not in kwargs: 34 | raise TypeError("At least a client_id must be provided.") 35 | 36 | if 'scope' in kwargs: 37 | self.scope = kwargs.get('scope') 38 | 39 | # decide which protocol flow to follow based on the arguments 40 | # provided by the caller. 41 | if self._options_for_authorization_code_flow_present(): 42 | self._authorization_code_flow() 43 | elif self._options_for_credentials_flow_present(): 44 | self._credentials_flow() 45 | elif self._options_for_token_refresh_present(): 46 | self._refresh_token_flow() 47 | 48 | def exchange_token(self, code): 49 | """Given the value of the code parameter, request an access token.""" 50 | url = '%s%s/oauth2/token' % (self.scheme, self.host) 51 | options = { 52 | 'grant_type': 'authorization_code', 53 | 'redirect_uri': self._redirect_uri(), 54 | 'client_id': self.options.get('client_id'), 55 | 'client_secret': self.options.get('client_secret'), 56 | 'code': code, 57 | } 58 | options.update({ 59 | 'verify_ssl': self.options.get('verify_ssl', True), 60 | 'proxies': self.options.get('proxies', None) 61 | }) 62 | self.token = wrapped_resource( 63 | make_request('post', url, options)) 64 | self.access_token = self.token.access_token 65 | return self.token 66 | 67 | def authorize_url(self): 68 | """Return the authorization URL for OAuth2 authorization code flow.""" 69 | return self._authorize_url 70 | 71 | def _authorization_code_flow(self): 72 | """Build the the auth URL so the user can authorize the app.""" 73 | options = { 74 | 'scope': getattr(self, 'scope', 'non-expiring'), 75 | 'client_id': self.options.get('client_id'), 76 | 'response_type': 'code', 77 | 'redirect_uri': self._redirect_uri() 78 | } 79 | url = '%s%s/connect' % (self.scheme, self.host) 80 | self._authorize_url = '%s?%s' % (url, urlencode(options)) 81 | 82 | def _refresh_token_flow(self): 83 | """Given a refresh token, obtain a new access token.""" 84 | url = '%s%s/oauth2/token' % (self.scheme, self.host) 85 | options = { 86 | 'grant_type': 'refresh_token', 87 | 'client_id': self.options.get('client_id'), 88 | 'client_secret': self.options.get('client_secret'), 89 | 'refresh_token': self.options.get('refresh_token') 90 | } 91 | options.update({ 92 | 'verify_ssl': self.options.get('verify_ssl', True), 93 | 'proxies': self.options.get('proxies', None) 94 | }) 95 | self.token = wrapped_resource( 96 | make_request('post', url, options)) 97 | self.access_token = self.token.access_token 98 | 99 | def _credentials_flow(self): 100 | """Given a username and password, obtain an access token.""" 101 | url = '%s%s/oauth2/token' % (self.scheme, self.host) 102 | options = { 103 | 'client_id': self.options.get('client_id'), 104 | 'client_secret': self.options.get('client_secret'), 105 | 'username': self.options.get('username'), 106 | 'password': self.options.get('password'), 107 | 'scope': getattr(self, 'scope', ''), 108 | 'grant_type': 'password' 109 | } 110 | options.update({ 111 | 'verify_ssl': self.options.get('verify_ssl', True), 112 | 'proxies': self.options.get('proxies', None) 113 | }) 114 | self.token = wrapped_resource( 115 | make_request('post', url, options)) 116 | self.access_token = self.token.access_token 117 | 118 | def _request(self, method, resource, **kwargs): 119 | """Given an HTTP method, a resource name and kwargs, construct a 120 | request and return the response. 121 | """ 122 | url = self._resolve_resource_name(resource) 123 | 124 | if hasattr(self, 'access_token'): 125 | kwargs.update(dict(oauth_token=self.access_token)) 126 | if hasattr(self, 'client_id'): 127 | kwargs.update(dict(client_id=self.client_id)) 128 | 129 | kwargs.update({ 130 | 'verify_ssl': self.options.get('verify_ssl', True), 131 | 'proxies': self.options.get('proxies', None) 132 | }) 133 | return wrapped_resource(make_request(method, url, kwargs)) 134 | 135 | def __getattr__(self, name, **kwargs): 136 | """Translate an HTTP verb into a request method.""" 137 | if name not in ('get', 'post', 'put', 'head', 'delete'): 138 | raise AttributeError 139 | return partial(self._request, name, **kwargs) 140 | 141 | def _resolve_resource_name(self, name): 142 | """Convert a resource name (e.g. tracks) into a URI.""" 143 | if name[:4] == 'http': # already a url 144 | return name 145 | name = name.rstrip('/').lstrip('/') 146 | return '%s%s/%s' % (self.scheme, self.host, name) 147 | 148 | def _redirect_uri(self): 149 | """ 150 | Return the redirect uri. Checks for ``redirect_uri`` or common typo, 151 | ``redirect_url`` 152 | """ 153 | return self.options.get( 154 | 'redirect_uri', 155 | self.options.get('redirect_url', None)) 156 | 157 | # Helper functions for testing arguments provided to the constructor. 158 | def _options_present(self, options, kwargs): 159 | return all(map(lambda k: k in kwargs, options)) 160 | 161 | def _options_for_credentials_flow_present(self): 162 | required = ('client_id', 'client_secret', 'username', 'password') 163 | return self._options_present(required, self.options) 164 | 165 | def _options_for_authorization_code_flow_present(self): 166 | required = ('client_id', 'redirect_uri') 167 | or_required = ('client_id', 'redirect_url') 168 | return (self._options_present(required, self.options) or 169 | self._options_present(or_required, self.options)) 170 | 171 | def _options_for_token_refresh_present(self): 172 | required = ('client_id', 'client_secret', 'refresh_token') 173 | return self._options_present(required, self.options) 174 | -------------------------------------------------------------------------------- /soundcloud/hashconversions.py: -------------------------------------------------------------------------------- 1 | import re 2 | import collections 3 | try: 4 | from urllib import quote_plus 5 | except ImportError: 6 | from urllib.parse import quote_plus 7 | 8 | import six 9 | 10 | 11 | def to_params(hash): 12 | normalized = [normalize_param(k, v) for (k, v) in six.iteritems(hash)] 13 | return dict((k, v) for d in normalized for (k, v) in d.items()) 14 | 15 | 16 | def normalize_param(key, value): 17 | """Convert a set of key, value parameters into a dictionary suitable for 18 | passing into requests. This will convert lists into the syntax required 19 | by SoundCloud. Heavily lifted from HTTParty. 20 | 21 | >>> normalize_param('playlist', { 22 | ... 'title': 'foo', 23 | ... 'sharing': 'private', 24 | ... 'tracks': [ 25 | ... {id: 1234}, {id: 4567} 26 | ... ]}) == { 27 | ... u'playlist[tracks][][]': [1234, 4567], 28 | ... u'playlist[sharing]': 'private', 29 | ... u'playlist[title]': 'foo'} # doctest:+ELLIPSIS 30 | True 31 | 32 | >>> normalize_param('oauth_token', 'foo') 33 | {'oauth_token': 'foo'} 34 | 35 | >>> normalize_param('playlist[tracks]', [1234, 4567]) == { 36 | ... u'playlist[tracks][]': [1234, 4567]} 37 | True 38 | """ 39 | params = {} 40 | stack = [] 41 | if isinstance(value, list): 42 | normalized = [normalize_param(u"{0[key]}[]".format(dict(key=key)), e) for e in value] 43 | keys = [item for sublist in tuple(h.keys() for h in normalized) for item in sublist] 44 | 45 | lists = {} 46 | if len(keys) != len(set(keys)): 47 | duplicates = [x for x, y in collections.Counter(keys).items() if y > 1] 48 | for dup in duplicates: 49 | lists[dup] = [h[dup] for h in normalized] 50 | for h in normalized: 51 | del h[dup] 52 | 53 | params.update(dict((k, v) for d in normalized for (k, v) in d.items())) 54 | params.update(lists) 55 | elif isinstance(value, dict): 56 | stack.append([key, value]) 57 | else: 58 | params.update({key: value}) 59 | 60 | for (parent, hash) in stack: 61 | for (key, value) in six.iteritems(hash): 62 | if isinstance(value, dict): 63 | stack.append([u"{0[parent]}[{0[key]}]".format(dict(parent=parent, key=key)), value]) 64 | else: 65 | params.update(normalize_param(u"{0[parent]}[{0[key]}]".format(dict(parent=parent, key=key)), value)) 66 | 67 | return params 68 | -------------------------------------------------------------------------------- /soundcloud/request.py: -------------------------------------------------------------------------------- 1 | try: 2 | from urllib import urlencode 3 | except ImportError: 4 | from urllib.parse import urlencode 5 | 6 | import requests 7 | import six 8 | 9 | import soundcloud 10 | 11 | from . import hashconversions 12 | 13 | 14 | def is_file_like(f): 15 | """Check to see if ```f``` has a ```read()``` method.""" 16 | return hasattr(f, 'read') and callable(f.read) 17 | 18 | 19 | def extract_files_from_dict(d): 20 | """Return any file objects from the provided dict. 21 | 22 | >>> extract_files_from_dict({ 23 | ... 'oauth_token': 'foo', 24 | ... 'track': { 25 | ... 'title': 'bar', 26 | ... 'asset_data': open('setup.py', 'rb') 27 | ... }}) # doctest:+ELLIPSIS 28 | {'track': {'asset_data': <...}} 29 | """ 30 | files = {} 31 | for key, value in six.iteritems(d): 32 | if isinstance(value, dict): 33 | files[key] = extract_files_from_dict(value) 34 | elif is_file_like(value): 35 | files[key] = value 36 | return files 37 | 38 | 39 | def remove_files_from_dict(d): 40 | """Return the provided dict with any file objects removed. 41 | 42 | >>> remove_files_from_dict({ 43 | ... 'oauth_token': 'foo', 44 | ... 'track': { 45 | ... 'title': 'bar', 46 | ... 'asset_data': open('setup.py', 'rb') 47 | ... } 48 | ... }) == {'track': {'title': 'bar'}, 'oauth_token': 'foo'} 49 | ... # doctest:+ELLIPSIS 50 | True 51 | """ 52 | file_free = {} 53 | for key, value in six.iteritems(d): 54 | if isinstance(value, dict): 55 | file_free[key] = remove_files_from_dict(value) 56 | elif not is_file_like(value): 57 | if hasattr(value, '__iter__'): 58 | file_free[key] = value 59 | else: 60 | if hasattr(value, 'encode'): 61 | file_free[key] = value.encode('utf-8') 62 | else: 63 | file_free[key] = str(value) 64 | return file_free 65 | 66 | 67 | def namespaced_query_string(d, prefix=""): 68 | """Transform a nested dict into a string with namespaced query params. 69 | 70 | >>> namespaced_query_string({ 71 | ... 'oauth_token': 'foo', 72 | ... 'track': {'title': 'bar', 'sharing': 'private'}}) == { 73 | ... 'track[sharing]': 'private', 74 | ... 'oauth_token': 'foo', 75 | ... 'track[title]': 'bar'} # doctest:+ELLIPSIS 76 | True 77 | """ 78 | qs = {} 79 | prefixed = lambda k: prefix and "%s[%s]" % (prefix, k) or k 80 | for key, value in six.iteritems(d): 81 | if isinstance(value, dict): 82 | qs.update(namespaced_query_string(value, prefix=key)) 83 | else: 84 | qs[prefixed(key)] = value 85 | return qs 86 | 87 | 88 | def make_request(method, url, params): 89 | """Make an HTTP request, formatting params as required.""" 90 | empty = [] 91 | 92 | # TODO 93 | # del params[key] 94 | # without list 95 | for key, value in six.iteritems(params): 96 | if value is None: 97 | empty.append(key) 98 | for key in empty: 99 | del params[key] 100 | 101 | # allow caller to disable automatic following of redirects 102 | allow_redirects = params.get('allow_redirects', True) 103 | 104 | kwargs = { 105 | 'allow_redirects': allow_redirects, 106 | 'headers': { 107 | 'User-Agent': soundcloud.USER_AGENT 108 | } 109 | } 110 | # options, not params 111 | if 'verify_ssl' in params: 112 | if params['verify_ssl'] is False: 113 | kwargs['verify'] = params['verify_ssl'] 114 | del params['verify_ssl'] 115 | if 'proxies' in params: 116 | kwargs['proxies'] = params['proxies'] 117 | del params['proxies'] 118 | if 'allow_redirects' in params: 119 | del params['allow_redirects'] 120 | 121 | params = hashconversions.to_params(params) 122 | files = namespaced_query_string(extract_files_from_dict(params)) 123 | data = namespaced_query_string(remove_files_from_dict(params)) 124 | 125 | request_func = getattr(requests, method, None) 126 | if request_func is None: 127 | raise TypeError('Unknown method: %s' % (method,)) 128 | 129 | if method == 'get': 130 | kwargs['headers']['Accept'] = 'application/json' 131 | qs = urlencode(data) 132 | if '?' in url: 133 | url_qs = '%s&%s' % (url, qs) 134 | else: 135 | url_qs = '%s?%s' % (url, qs) 136 | result = request_func(url_qs, **kwargs) 137 | else: 138 | kwargs['data'] = data 139 | if files: 140 | kwargs['files'] = files 141 | result = request_func(url, **kwargs) 142 | 143 | # if redirects are disabled, don't raise for 301 / 302 144 | if result.status_code in (301, 302): 145 | if allow_redirects: 146 | result.raise_for_status() 147 | else: 148 | result.raise_for_status() 149 | return result 150 | -------------------------------------------------------------------------------- /soundcloud/resource.py: -------------------------------------------------------------------------------- 1 | try: 2 | import json 3 | except ImportError: 4 | import simplejson as json 5 | 6 | try: 7 | from UserList import UserList 8 | except ImportError: 9 | from collections import UserList 10 | 11 | 12 | class Resource(object): 13 | """Object wrapper for resources. 14 | 15 | Provides an object interface to resources returned by the Soundcloud API. 16 | """ 17 | def __init__(self, obj): 18 | self.obj = obj 19 | if hasattr(self, 'origin'): 20 | self.origin = Resource(self.origin) 21 | 22 | def __getstate__(self): 23 | return self.obj.items() 24 | 25 | def __setstate__(self, items): 26 | if not hasattr(self, 'obj'): 27 | self.obj = {} 28 | for key, val in items: 29 | self.obj[key] = val 30 | 31 | def __getattr__(self, name): 32 | if name in self.obj: 33 | return self.obj.get(name) 34 | raise AttributeError 35 | 36 | def fields(self): 37 | return self.obj 38 | 39 | def keys(self): 40 | return self.obj.keys() 41 | 42 | 43 | class ResourceList(UserList): 44 | """Object wrapper for lists of resources.""" 45 | def __init__(self, resources=[]): 46 | data = [Resource(resource) for resource in resources] 47 | super(ResourceList, self).__init__(data) 48 | 49 | 50 | def wrapped_resource(response): 51 | """Return a response wrapped in the appropriate wrapper type. 52 | 53 | Lists will be returned as a ```ResourceList``` instance, 54 | dicts will be returned as a ```Resource``` instance. 55 | """ 56 | # decode response text, assuming utf-8 if unset 57 | response_content = response.content.decode(response.encoding or 'utf-8') 58 | 59 | try: 60 | content = json.loads(response_content) 61 | except ValueError: 62 | # not JSON 63 | content = response_content 64 | if isinstance(content, list): 65 | result = ResourceList(content) 66 | else: 67 | result = Resource(content) 68 | if hasattr(result, 'collection'): 69 | result.collection = ResourceList(result.collection) 70 | result.raw_data = response_content 71 | 72 | for attr in ('encoding', 'url', 'status_code', 'reason'): 73 | setattr(result, attr, getattr(response, attr)) 74 | 75 | return result 76 | -------------------------------------------------------------------------------- /soundcloud/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soundcloud/soundcloud-python/4f7050182ee37e7c503253ffa79a09a2b55742cf/soundcloud/tests/__init__.py -------------------------------------------------------------------------------- /soundcloud/tests/test_client.py: -------------------------------------------------------------------------------- 1 | import soundcloud 2 | 3 | from soundcloud.tests.utils import MockResponse 4 | 5 | try: 6 | from urllib import urlencode 7 | except ImportError: 8 | from urllib.parse import urlencode 9 | 10 | from nose.tools import eq_, raises 11 | from fudge import patch 12 | 13 | 14 | def test_kwargs_parsing_valid(): 15 | """Test that valid kwargs are stored as properties on the client.""" 16 | client = soundcloud.Client(client_id='foo', client_secret='foo') 17 | assert isinstance(client, soundcloud.Client) 18 | eq_('foo', client.client_id) 19 | client = soundcloud.Client(client_id='foo', client_secret='bar', 20 | access_token='baz', username='you', 21 | password='secret', redirect_uri='foooo') 22 | eq_('foo', client.client_id) 23 | eq_('baz', client.access_token) 24 | 25 | 26 | @raises(AttributeError) 27 | def test_kwargs_parsing_invalid(): 28 | """Test that unknown kwargs are ignored.""" 29 | client = soundcloud.Client(foo='bar', client_id='bar') 30 | client.foo 31 | 32 | 33 | def test_url_creation(): 34 | """Test that resources are turned into urls properly.""" 35 | client = soundcloud.Client(client_id='foo') 36 | url = client._resolve_resource_name('tracks') 37 | eq_('https://api.soundcloud.com/tracks', url) 38 | url = client._resolve_resource_name('/tracks/') 39 | eq_('https://api.soundcloud.com/tracks', url) 40 | 41 | 42 | def test_url_creation_options(): 43 | """Test that resource resolving works with different options.""" 44 | client = soundcloud.Client(client_id='foo', use_ssl=False) 45 | client.host = 'soundcloud.dev' 46 | url = client._resolve_resource_name('apps/132445') 47 | eq_('http://soundcloud.dev/apps/132445', url) 48 | 49 | 50 | def test_method_dispatching(): 51 | """Test that getattr is doing right by us.""" 52 | client = soundcloud.Client(client_id='foo') 53 | for method in ('get', 'post', 'put', 'delete', 'head'): 54 | p = getattr(client, method) 55 | eq_((method,), p.args) 56 | eq_('_request', p.func.__name__) 57 | 58 | 59 | def test_host_config(): 60 | """We should be able to set the host on the client.""" 61 | client = soundcloud.Client(client_id='foo', host='api.soundcloud.dev') 62 | eq_('api.soundcloud.dev', client.host) 63 | client = soundcloud.Client(client_id='foo') 64 | eq_('api.soundcloud.com', client.host) 65 | 66 | 67 | @patch('requests.get') 68 | def test_disabling_ssl_verification(fake_get): 69 | """We should be able to disable ssl verification when we are in dev mode""" 70 | client = soundcloud.Client(client_id='foo', host='api.soundcloud.dev', 71 | verify_ssl=False) 72 | expected_url = '%s?%s' % ( 73 | client._resolve_resource_name('tracks'), 74 | urlencode({ 75 | 'limit': 5, 76 | 'client_id': 'foo' 77 | })) 78 | headers = { 79 | 'User-Agent': soundcloud.USER_AGENT, 80 | 'Accept': 'application/json' 81 | } 82 | (fake_get.expects_call() 83 | .with_args(expected_url, 84 | headers=headers, 85 | verify=False, 86 | allow_redirects=True) 87 | .returns(MockResponse("{}"))) 88 | client.get('tracks', limit=5) 89 | 90 | 91 | @raises(AttributeError) 92 | def test_method_dispatching_invalid_method(): 93 | """Test that getattr raises an attributeerror if we give it garbage.""" 94 | client = soundcloud.Client(client_id='foo') 95 | client.foo() 96 | 97 | 98 | @patch('requests.get') 99 | def test_method_dispatching_get_request_readonly(fake_get): 100 | """Test that calling client.get() results in a proper call 101 | to the get function in the requests module with the provided 102 | kwargs as the querystring. 103 | """ 104 | client = soundcloud.Client(client_id='foo') 105 | expected_url = '%s?%s' % ( 106 | client._resolve_resource_name('tracks'), 107 | urlencode({ 108 | 'limit': 5, 109 | 'client_id': 'foo' 110 | })) 111 | headers = { 112 | 'User-Agent': soundcloud.USER_AGENT, 113 | 'Accept': 'application/json' 114 | } 115 | (fake_get.expects_call() 116 | .with_args(expected_url, headers=headers, allow_redirects=True) 117 | .returns(MockResponse("{}"))) 118 | client.get('tracks', limit=5) 119 | 120 | 121 | @patch('requests.post') 122 | def test_method_dispatching_post_request(fake_post): 123 | """Test that calling client.post() results in a proper call 124 | to the post function in the requests module. 125 | 126 | TODO: Revise once read/write support has been added. 127 | """ 128 | client = soundcloud.Client(client_id='foo') 129 | expected_url = client._resolve_resource_name('tracks') 130 | data = { 131 | 'client_id': 'foo' 132 | } 133 | headers = { 134 | 'User-Agent': soundcloud.USER_AGENT 135 | } 136 | (fake_post.expects_call() 137 | .with_args(expected_url, 138 | data=data, 139 | headers=headers, 140 | allow_redirects=True) 141 | .returns(MockResponse("{}"))) 142 | client.post('tracks') 143 | 144 | 145 | @patch('requests.get') 146 | def test_proxy_servers(fake_request): 147 | """Test that providing a dictionary of proxy servers works.""" 148 | proxies = { 149 | 'http': 'myproxyserver:1234' 150 | } 151 | client = soundcloud.Client(client_id='foo', proxies=proxies) 152 | expected_url = "%s?%s" % ( 153 | client._resolve_resource_name('me'), 154 | urlencode({ 155 | 'client_id': 'foo' 156 | }) 157 | ) 158 | headers = { 159 | 'User-Agent': soundcloud.USER_AGENT, 160 | 'Accept': 'application/json' 161 | } 162 | (fake_request.expects_call() 163 | .with_args(expected_url, 164 | headers=headers, 165 | proxies=proxies, 166 | allow_redirects=True) 167 | .returns(MockResponse("{}"))) 168 | client.get('/me') 169 | -------------------------------------------------------------------------------- /soundcloud/tests/test_encoding.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 2 | import soundcloud 3 | 4 | from soundcloud.tests.utils import MockResponse 5 | 6 | from fudge import patch 7 | 8 | 9 | @patch('requests.put') 10 | def test_non_ascii_data(fake_put): 11 | """Test that non-ascii characters are accepted.""" 12 | client = soundcloud.Client(client_id='foo', client_secret='foo') 13 | title = u'Föo Baß' 14 | fake_put.expects_call().returns(MockResponse("{}")) 15 | client.put('/tracks', track={ 16 | 'title': title 17 | }) 18 | -------------------------------------------------------------------------------- /soundcloud/tests/test_oauth.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | try: 3 | from urllib import urlencode 4 | except ImportError: 5 | from urllib.parse import urlencode 6 | 7 | from nose.tools import eq_ 8 | 9 | import fudge 10 | import soundcloud 11 | 12 | from soundcloud.tests.utils import MockResponse 13 | 14 | 15 | @contextmanager 16 | def non_expiring_token_response(fake_http_request): 17 | response = MockResponse( 18 | '{"access_token":"access-1234","scope":"non-expiring"}') 19 | fake_http_request.expects_call().returns(response) 20 | yield 21 | 22 | 23 | @contextmanager 24 | def expiring_token_response(fake_http_request): 25 | response = MockResponse( 26 | '{"access_token":"access-1234","expires_in":12345,"scope":"*",' + 27 | '"refresh_token":"refresh-1234"}') 28 | fake_http_request.expects_call().returns(response) 29 | yield 30 | 31 | 32 | @contextmanager 33 | def positive_refresh_token_response(fake_http_request): 34 | response = MockResponse( 35 | '{"access_token":"access-2345","expires_in":21599,"scope":"*",' + 36 | '"refresh_token":"refresh-2345"}') 37 | fake_http_request.expects_call().returns(response) 38 | yield 39 | 40 | 41 | def test_authorize_url_construction(): 42 | """Test that authorize url is being generated properly.""" 43 | client = soundcloud.Client(client_id='foo', client_secret='bar', 44 | redirect_uri='http://example.com/callback') 45 | eq_('https://api.soundcloud.com/connect?%s' % (urlencode({ 46 | 'scope': 'non-expiring', 47 | 'client_id': 'foo', 48 | 'response_type': 'code', 49 | 'redirect_uri': 'http://example.com/callback' 50 | }),), client.authorize_url()) 51 | 52 | 53 | @fudge.patch('requests.post') 54 | def test_exchange_code_non_expiring(fake): 55 | """Test that exchanging a code for an access token works.""" 56 | with non_expiring_token_response(fake): 57 | client = soundcloud.Client(client_id='foo', client_secret='bar', 58 | redirect_uri='http://example.com/callback') 59 | token = client.exchange_token('this-is-a-code') 60 | eq_('access-1234', token.access_token) 61 | eq_('non-expiring', token.scope) 62 | eq_('access-1234', client.access_token) 63 | 64 | 65 | @fudge.patch('requests.post') 66 | def test_exchange_code_expiring(fake): 67 | """Excluding a scope=non-expiring arg should generate a refresh token.""" 68 | with expiring_token_response(fake): 69 | client = soundcloud.Client(client_id='foo', client_secret='bar', 70 | redirect_uri='http://example.com/callback', 71 | scope='*') 72 | eq_('https://api.soundcloud.com/connect?%s' % (urlencode({ 73 | 'scope': '*', 74 | 'client_id': 'foo', 75 | 'response_type': 'code', 76 | 'redirect_uri': 'http://example.com/callback' 77 | }),), client.authorize_url()) 78 | token = client.exchange_token('this-is-a-code') 79 | eq_('access-1234', token.access_token) 80 | eq_('refresh-1234', token.refresh_token) 81 | 82 | 83 | @fudge.patch('requests.post') 84 | def test_refresh_token_flow(fake): 85 | """Providing a refresh token should generate a new access token.""" 86 | with positive_refresh_token_response(fake): 87 | client = soundcloud.Client(client_id='foo', client_secret='bar', 88 | refresh_token='refresh-token') 89 | eq_('access-2345', client.token.access_token) 90 | -------------------------------------------------------------------------------- /soundcloud/tests/test_requests.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | 3 | import fudge 4 | import soundcloud 5 | 6 | from nose.tools import raises, assert_raises 7 | from requests.exceptions import HTTPError 8 | 9 | from soundcloud.tests.utils import MockResponse 10 | 11 | 12 | class MockRaw(object): 13 | """Simple mock for the raw response in requests model.""" 14 | def __init__(self): 15 | self.reason = "foo" 16 | 17 | 18 | @contextmanager 19 | def response_status(fake_http_request, status): 20 | response = MockResponse('{}', status_code=status) 21 | response.raw = MockRaw() 22 | fake_http_request.expects_call().returns(response) 23 | yield 24 | 25 | 26 | @fudge.patch('requests.get') 27 | def test_bad_responses(fake): 28 | """Anything in the 400 or 500 range should raise an exception.""" 29 | client = soundcloud.Client(client_id='foo', client_secret='foo') 30 | 31 | for status in range(400, 423): 32 | with response_status(fake, status): 33 | assert_raises(HTTPError, lambda: client.get('/me')) 34 | for status in (500, 501, 502, 503, 504, 505): 35 | with response_status(fake, status): 36 | assert_raises(HTTPError, lambda: client.get('/me')) 37 | 38 | @fudge.patch('requests.get') 39 | def test_ok_response(fake): 40 | """A 200 range response should be fine.""" 41 | client = soundcloud.Client(client_id='foo', client_secret='foo') 42 | for status in (200, 201, 202, 203, 204, 205, 206): 43 | with response_status(fake, status): 44 | user = client.get('/me') 45 | 46 | -------------------------------------------------------------------------------- /soundcloud/tests/test_resource.py: -------------------------------------------------------------------------------- 1 | try: 2 | import json 3 | except ImportError: 4 | import simplejson as json 5 | 6 | from soundcloud.resource import wrapped_resource, ResourceList, Resource 7 | from soundcloud.tests.utils import MockResponse 8 | 9 | from nose.tools import eq_ 10 | 11 | 12 | def test_json_list(): 13 | """Verify that a json list is wrapped in a ResourceList object.""" 14 | resources = wrapped_resource(MockResponse(json.dumps([{'foo': 'bar'}]), 15 | encoding='utf-8')) 16 | assert isinstance(resources, ResourceList) 17 | eq_(1, len(resources)) 18 | eq_('bar', resources[0].foo) 19 | 20 | 21 | def test_json_object(): 22 | """Verify that a json object is wrapped in a Resource object.""" 23 | resource = wrapped_resource(MockResponse(json.dumps({'foo': 'bar'}), 24 | encoding='utf-8')) 25 | assert isinstance(resource, Resource) 26 | eq_('bar', resource.foo) 27 | 28 | 29 | def test_properties_copied(): 30 | """Certain properties should be copied to the wrapped resource.""" 31 | response = MockResponse(json.dumps({'foo': 'bar'}), 32 | encoding='utf-8', 33 | status_code=200, 34 | reason='OK', 35 | url='http://example.com') 36 | resource = wrapped_resource(response) 37 | eq_(200, resource.status_code) 38 | eq_('OK', resource.reason) 39 | eq_('utf-8', resource.encoding) 40 | eq_('http://example.com', resource.url) 41 | -------------------------------------------------------------------------------- /soundcloud/tests/utils.py: -------------------------------------------------------------------------------- 1 | from requests.models import Response 2 | 3 | 4 | class MockResponse(Response): 5 | def __init__(self, content, encoding='utf-8', 6 | status_code=200, url=None, reason='OK'): 7 | self._content = content.encode('utf-8') 8 | self.encoding = encoding 9 | self.status_code = status_code 10 | self.url = url 11 | self.reason = reason 12 | --------------------------------------------------------------------------------