├── .gitignore ├── .travis.yml ├── CHANGES ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── dev-requirements.txt ├── disqusapi ├── __init__.py ├── compat.py ├── interfaces.json ├── paginator.py ├── tests.py ├── tests_compat.py └── utils.py ├── setup.cfg ├── setup.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | dist/ 3 | build/ 4 | *.egg-info/ 5 | nosetests.xml 6 | *.egg 7 | .tox 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.6" 4 | - "2.7" 5 | - "3.2" 6 | - "3.3" 7 | - "3.4" 8 | - "pypy" 9 | install: 10 | - pip install -r dev-requirements.txt 11 | script: 12 | - flake8 disqusapi/ -v 13 | - py.test -v 14 | -------------------------------------------------------------------------------- /CHANGES: -------------------------------------------------------------------------------- 1 | 0.5.0 2 | 3 | * Added tox support. 4 | * New invocation api.(, ) where endpoint 5 | is a string. This to add compatibility when the endpoint contains 6 | reserved words. 7 | * Python 3 support. 8 | * Removed simplejson dependency 9 | * Travis CI support. 10 | * Added socket timeout. 11 | * Deprecated the `setSecretKey` and `setKey` methods in favor of the `secret_key` attribute. 12 | * Deprecated the `key` property in favor of the `secret_key` attribute. 13 | * Deprecated the `setPublicKey` method in favor of directly setting the `public_key` attribute. 14 | * Deprecated the `setFormat` method in favor of directly setting the `format` attribute. 15 | * Deprecated the `setVersion` method in favor of directly setting the `version` attribute. 16 | * Deprecated the `setTimeout` method in favor of directly setting the `timeout` attribute. 17 | * Request gzipped responses from API. 18 | 19 | 0.4.1 20 | 21 | * Fixed behavior when api_secret or api_public are not set. 22 | 23 | 0.4.0 24 | 25 | * Removed signed requests (Disqus has deprecated them). 26 | * Change all endpoints to use SSL. 27 | 28 | 0.3.2 29 | 30 | * Added support for undefined interfaces. 31 | * Added support for mapping error codes to different exceptions. 32 | 33 | 0.3.1 34 | 35 | * Fixed an issue with GET requests and the normalized request string. 36 | 37 | 0.3.0 38 | 39 | * Added signed requests (you must pass public_key to DisqusAPI). 40 | * Added support for OAuth (via access_token in signed requests). 41 | * Moved Paginator into disqusapi.paginator 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2010 DISQUS 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include setup.py README.rst MANIFEST.in LICENSE disqusapi/interfaces.json 2 | global-exclude *~ -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | dev: 2 | pip install -r dev-requirements.txt 3 | 4 | lint: 5 | flake8 disqusapi/ 6 | 7 | test: lint 8 | py.test 9 | 10 | clean: 11 | rm -rf *.egg-info *.egg dist/ build/ 12 | 13 | release: 14 | python setup.py sdist bdist_wheel 15 | twine upload dist/* 16 | 17 | .PHONY: dev lint test clean release 18 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | disqus-python 2 | ~~~~~~~~~~~~~ 3 | 4 | .. image:: https://travis-ci.org/disqus/disqus-python.svg?branch=master 5 | :target: https://travis-ci.org/disqus/disqus-python 6 | 7 | Let's start with installing the API: 8 | 9 | pip install disqus-python 10 | 11 | Use the API by instantiating it, and then calling the method through dotted notation chaining:: 12 | 13 | from disqusapi import DisqusAPI 14 | disqus = DisqusAPI(secret_key, public_key) 15 | for result in disqus.get('trends.listThreads'): 16 | print result 17 | 18 | Parameters (including the ability to override version, api_secret, and format) are passed as keyword arguments to the resource call:: 19 | 20 | disqus.get('posts.details', post=1, version='3.0') 21 | 22 | Paginating through endpoints is easy as well:: 23 | 24 | from disqusapi import Paginator 25 | paginator = Paginator(api.get, 'trends.listThreads', forum='disqus') 26 | for result in paginator: 27 | print result 28 | 29 | # pull in a maximum of 500 results (this limit param differs from the endpoint's limit param) 30 | for result in paginator(limit=500): 31 | print result 32 | 33 | Documentation on all methods, as well as general API usage can be found at https://disqus.com/api/docs/ 34 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | -e . 2 | flake8 3 | mock 4 | pytest 5 | twine 6 | wheel 7 | -------------------------------------------------------------------------------- /disqusapi/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | disqus-python 3 | ~~~~~~~~~~~~~ 4 | 5 | disqus = DisqusAPI(api_secret=secret_key) 6 | disqus.get('trends.listThreads') 7 | 8 | """ 9 | try: 10 | __version__ = __import__('pkg_resources') \ 11 | .get_distribution('disqusapi').version 12 | except Exception: # pragma: no cover 13 | __version__ = 'unknown' 14 | 15 | import re 16 | import zlib 17 | import os.path 18 | import warnings 19 | import socket 20 | 21 | try: 22 | import simplejson as json 23 | except ImportError: 24 | import json 25 | 26 | from disqusapi.paginator import Paginator 27 | from disqusapi import compat 28 | from disqusapi.compat import http_client as httplib 29 | from disqusapi.compat import urllib_parse as urllib 30 | from disqusapi.utils import build_interfaces_by_method 31 | 32 | __all__ = ['DisqusAPI', 'Paginator'] 33 | 34 | with open(os.path.join(os.path.dirname(__file__), 'interfaces.json')) as fp: 35 | INTERFACES = json.load(fp) 36 | 37 | HOST = 'disqus.com' 38 | 39 | CHARSET_RE = re.compile(r'charset=(\S+)') 40 | DEFAULT_ENCODING = 'utf-8' 41 | 42 | 43 | class InterfaceNotDefined(NotImplementedError): 44 | pass 45 | 46 | 47 | class InvalidHTTPMethod(TypeError): 48 | def __init__(self, message): 49 | self.message = message 50 | 51 | def __str__(self): 52 | return "expected 'GET' or 'POST', got: %r" % self.message 53 | 54 | 55 | class FormattingError(ValueError): 56 | pass 57 | 58 | 59 | class APIError(Exception): 60 | def __init__(self, code, message): 61 | self.code = code 62 | self.message = message 63 | 64 | def __str__(self): 65 | return '%s: %s' % (self.code, self.message) 66 | 67 | 68 | class InvalidAccessToken(APIError): 69 | pass 70 | 71 | ERROR_MAP = { 72 | 18: InvalidAccessToken, 73 | } 74 | 75 | 76 | class Result(object): 77 | def __init__(self, response, cursor=None): 78 | self.response = response 79 | self.cursor = cursor or {} 80 | 81 | def __repr__(self): 82 | return '<%s: %s>' % (self.__class__.__name__, repr(self.response)) 83 | 84 | def __iter__(self): 85 | for r in self.response: 86 | yield r 87 | 88 | def __len__(self): 89 | return len(self.response) 90 | 91 | def __getslice__(self, i, j): 92 | return list.__getslice__(self.response, i, j) 93 | 94 | def __getitem__(self, key): 95 | return list.__getitem__(self.response, key) 96 | 97 | def __contains__(self, key): 98 | return list.__contains__(self.response, key) 99 | 100 | 101 | class Resource(object): 102 | def __init__(self, api, interfaces=INTERFACES, node=None, tree=()): 103 | self.api = api 104 | self.node = node 105 | self.interfaces = interfaces 106 | if node: 107 | tree = tree + (node,) 108 | self.tree = tree 109 | self.interfaces_by_method = {} 110 | 111 | def update_interface(self, interface): 112 | raise NotImplemented 113 | 114 | def __getattr__(self, attr): 115 | if attr in getattr(self, '__dict__'): 116 | return getattr(self, attr) 117 | if attr == 'interface': 118 | raise InterfaceNotDefined('You must use ``update_interface`` now.') 119 | interface = {} 120 | try: 121 | interface = self.interfaces[attr] 122 | except KeyError: 123 | try: 124 | interface = self.interfaces_by_method[attr] 125 | except KeyError: 126 | pass 127 | return Resource(self.api, interface, attr, self.tree) 128 | 129 | def __call__(self, endpoint=None, **kwargs): 130 | return self._request(endpoint, **kwargs) 131 | 132 | def _request(self, endpoint=None, **kwargs): 133 | if endpoint is not None: 134 | # Handle undefined interfaces 135 | resource = self.interfaces.get(endpoint, {}) 136 | endpoint = endpoint.replace('.', '/') 137 | else: 138 | resource = self.interfaces 139 | endpoint = '/'.join(self.tree) 140 | for k in resource.get('required', []): 141 | if k not in (x.split(':')[0] for x in compat.iterkeys(kwargs)): 142 | raise ValueError('Missing required argument: %s' % k) 143 | 144 | method = kwargs.pop('method', resource.get('method')) 145 | 146 | if not method: 147 | raise InterfaceNotDefined( 148 | 'Interface is not defined, you must pass ``method`` (HTTP Method).') 149 | 150 | method = method.upper() 151 | if method not in ('GET', 'POST'): 152 | raise InvalidHTTPMethod(method) 153 | 154 | api = self.api 155 | 156 | version = kwargs.pop('version', api.version) 157 | format = kwargs.pop('format', api.format) 158 | formatter, formatter_error = api.formats[format] 159 | 160 | path = '/api/%s/%s.%s' % (version, endpoint, format) 161 | 162 | if 'api_secret' not in kwargs and api.secret_key: 163 | kwargs['api_secret'] = api.secret_key 164 | if 'api_public' not in kwargs and api.public_key: 165 | kwargs['api_key'] = api.public_key 166 | 167 | # We need to ensure this is a list so that 168 | # multiple values for a key work 169 | params = [] 170 | for k, v in compat.iteritems(kwargs): 171 | if isinstance(v, (list, tuple)): 172 | for val in v: 173 | params.append((k, val)) 174 | else: 175 | params.append((k, v)) 176 | 177 | headers = { 178 | 'User-Agent': 'disqus-python/%s' % __version__, 179 | 'Accept-Encoding': 'gzip', 180 | } 181 | 182 | if method == 'GET': 183 | path = '%s?%s' % (path, urllib.urlencode(params)) 184 | data = '' 185 | else: 186 | data = urllib.urlencode(params) 187 | headers['Content-Type'] = 'application/x-www-form-urlencoded' 188 | 189 | conn = httplib.HTTPSConnection(HOST, timeout=api.timeout) 190 | conn.request(method, path, data, headers) 191 | response = conn.getresponse() 192 | 193 | try: 194 | body = response.read() 195 | finally: 196 | # Close connection 197 | conn.close() 198 | 199 | if response.getheader('Content-Encoding') == 'gzip': 200 | # See: http://stackoverflow.com/a/2424549 201 | body = zlib.decompress(body, 16 + zlib.MAX_WBITS) 202 | 203 | # Determine the encoding of the response and respect 204 | # the Content-Type header, but default back to utf-8 205 | content_type = response.getheader('Content-Type') 206 | if content_type is None: 207 | encoding = DEFAULT_ENCODING 208 | else: 209 | try: 210 | encoding = CHARSET_RE.search(content_type).group(1) 211 | except AttributeError: 212 | encoding = DEFAULT_ENCODING 213 | 214 | body = body.decode(encoding) 215 | 216 | try: 217 | # Coerce response to Python 218 | data = formatter(body) 219 | except formatter_error: 220 | raise FormattingError(body) 221 | 222 | if response.status != 200: 223 | raise ERROR_MAP.get(data['code'], APIError)(data['code'], data['response']) 224 | 225 | if isinstance(data['response'], list): 226 | return Result(data['response'], data.get('cursor')) 227 | return data['response'] 228 | 229 | 230 | class DisqusAPI(Resource): 231 | formats = { 232 | 'json': (json.loads, ValueError), 233 | } 234 | 235 | def __init__(self, secret_key=None, public_key=None, format='json', version='3.0', 236 | timeout=None, interfaces=INTERFACES, **kwargs): 237 | self.secret_key = secret_key 238 | self.public_key = public_key 239 | if not public_key: 240 | warnings.warn('You should pass ``public_key`` in addition to your secret key.') 241 | self.format = format 242 | self.version = version 243 | self.timeout = timeout or socket.getdefaulttimeout() 244 | self.interfaces = interfaces 245 | self.interfaces_by_method = build_interfaces_by_method(self.interfaces) 246 | super(DisqusAPI, self).__init__(self) 247 | 248 | @property 249 | def key(self): 250 | warnings.warn( 251 | "'key' is deprecated in favor of directly accessing the `secret_key` attribute", 252 | DeprecationWarning) 253 | return self.secret_key 254 | 255 | def setSecretKey(self, key): 256 | warnings.warn( 257 | "'setSecretKey' is deprecated in favor of directly setting the 'secret_key' attribute", 258 | DeprecationWarning) 259 | self.secret_key = key 260 | 261 | def setKey(self, key): 262 | warnings.warn( 263 | "'setKey' is deprecated in favor of directly setting the 'secret_key' attribute", 264 | DeprecationWarning) 265 | self.secret_key = key 266 | 267 | def setPublicKey(self, key): 268 | warnings.warn( 269 | "'setPublicKey' is deprecated in favor of directly setting the 'public_key' attribute", 270 | DeprecationWarning) 271 | self.public_key = key 272 | 273 | def setFormat(self, format): 274 | warnings.warn( 275 | "'setFormat' is deprecated in favor of directly setting the 'format' attribute", 276 | DeprecationWarning) 277 | self.format = format 278 | 279 | def setVersion(self, version): 280 | warnings.warn( 281 | "'setVersion' is deprecated in favor of directly setting the 'version' attribute", 282 | DeprecationWarning) 283 | self.version = version 284 | 285 | def setTimeout(self, timeout): 286 | warnings.warn( 287 | "'setTimeout' is deprecated in favor of directly setting the 'timeout' attribute", 288 | DeprecationWarning) 289 | self.timeout = timeout 290 | 291 | def update_interface(self, new_interface): 292 | self.interfaces.update(new_interface) 293 | self.interfaces_by_method = build_interfaces_by_method(self.interfaces) 294 | -------------------------------------------------------------------------------- /disqusapi/compat.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | PY3 = sys.version_info[0] == 3 4 | 5 | if PY3: 6 | def iterkeys(d, **kw): 7 | return iter(d.keys(**kw)) 8 | 9 | def iteritems(d, **kw): 10 | return iter(d.items(**kw)) 11 | 12 | xrange = range 13 | 14 | import http.client as http_client # NOQA 15 | import urllib.parse as urllib_parse # NOQA 16 | else: 17 | def iterkeys(d, **kw): 18 | return iter(d.iterkeys(**kw)) 19 | 20 | def iteritems(d, **kw): 21 | return iter(d.iteritems(**kw)) 22 | 23 | xrange = xrange 24 | 25 | import httplib as http_client # NOQA 26 | import urllib as urllib_parse # NOQA 27 | -------------------------------------------------------------------------------- /disqusapi/interfaces.json: -------------------------------------------------------------------------------- 1 | { 2 | "reactions": { 3 | "restore": { 4 | "required": [ 5 | "reaction", 6 | "forum" 7 | ], 8 | "method": "POST", 9 | "formats": [ 10 | "json", 11 | "jsonp" 12 | ] 13 | }, 14 | "list": { 15 | "required": [ 16 | "forum" 17 | ], 18 | "method": "POST", 19 | "formats": [ 20 | "json", 21 | "jsonp" 22 | ] 23 | }, 24 | "details": { 25 | "required": [ 26 | "reaction", 27 | "forum" 28 | ], 29 | "method": "GET", 30 | "formats": [ 31 | "json", 32 | "jsonp" 33 | ] 34 | }, 35 | "remove": { 36 | "required": [ 37 | "reaction", 38 | "forum" 39 | ], 40 | "method": "POST", 41 | "formats": [ 42 | "json", 43 | "jsonp" 44 | ] 45 | } 46 | }, 47 | "exports": { 48 | "exportForum": { 49 | "required": [ 50 | "forum" 51 | ], 52 | "method": "POST", 53 | "formats": [ 54 | "json", 55 | "jsonp" 56 | ] 57 | } 58 | }, 59 | "users": { 60 | "listActiveForums": { 61 | "required": [], 62 | "method": "GET", 63 | "formats": [ 64 | "json", 65 | "jsonp" 66 | ] 67 | }, 68 | "listActiveThreads": { 69 | "required": [], 70 | "method": "GET", 71 | "formats": [ 72 | "json", 73 | "jsonp", 74 | "rss" 75 | ] 76 | }, 77 | "listFollowing": { 78 | "required": [], 79 | "method": "GET", 80 | "formats": [ 81 | "json", 82 | "jsonp" 83 | ] 84 | }, 85 | "listForums": { 86 | "required": [], 87 | "method": "GET", 88 | "formats": [ 89 | "json", 90 | "jsonp" 91 | ] 92 | }, 93 | "unfollow": { 94 | "required": [ 95 | "target" 96 | ], 97 | "method": "POST", 98 | "formats": [ 99 | "json", 100 | "jsonp" 101 | ] 102 | }, 103 | "listPosts": { 104 | "required": [], 105 | "method": "GET", 106 | "formats": [ 107 | "json", 108 | "jsonp", 109 | "rss" 110 | ] 111 | }, 112 | "details": { 113 | "required": [], 114 | "method": "GET", 115 | "formats": [ 116 | "json", 117 | "jsonp" 118 | ] 119 | }, 120 | "listFollowers": { 121 | "required": [], 122 | "method": "GET", 123 | "formats": [ 124 | "json", 125 | "jsonp" 126 | ] 127 | }, 128 | "follow": { 129 | "required": [ 130 | "target" 131 | ], 132 | "method": "POST", 133 | "formats": [ 134 | "json", 135 | "jsonp" 136 | ] 137 | }, 138 | "listActivity": { 139 | "required": [], 140 | "method": "GET", 141 | "formats": [ 142 | "json", 143 | "jsonp" 144 | ] 145 | }, 146 | "listMostActiveForums": { 147 | "required": [], 148 | "method": "GET", 149 | "formats": [ 150 | "json", 151 | "jsonp" 152 | ] 153 | } 154 | }, 155 | "imports": { 156 | "list": { 157 | "required": [ 158 | "forum" 159 | ], 160 | "method": "GET", 161 | "formats": [ 162 | "json", 163 | "jsonp" 164 | ] 165 | }, 166 | "details": { 167 | "required": [ 168 | "group", 169 | "forum" 170 | ], 171 | "method": "GET", 172 | "formats": [ 173 | "json", 174 | "jsonp" 175 | ] 176 | } 177 | }, 178 | "posts": { 179 | "restore": { 180 | "required": [ 181 | "post" 182 | ], 183 | "method": "POST", 184 | "formats": [ 185 | "json", 186 | "jsonp" 187 | ] 188 | }, 189 | "spam": { 190 | "required": [ 191 | "post" 192 | ], 193 | "method": "POST", 194 | "formats": [ 195 | "json", 196 | "jsonp" 197 | ] 198 | }, 199 | "unhighlight": { 200 | "required": [ 201 | "post" 202 | ], 203 | "method": "POST", 204 | "formats": [ 205 | "json", 206 | "jsonp" 207 | ] 208 | }, 209 | "listPopular": { 210 | "required": [], 211 | "method": "GET", 212 | "formats": [ 213 | "json", 214 | "jsonp", 215 | "rss" 216 | ] 217 | }, 218 | "create": { 219 | "required": [ 220 | "message" 221 | ], 222 | "method": "POST", 223 | "formats": [ 224 | "json", 225 | "jsonp" 226 | ] 227 | }, 228 | "list": { 229 | "required": [], 230 | "method": "GET", 231 | "formats": [ 232 | "json", 233 | "jsonp", 234 | "rss" 235 | ] 236 | }, 237 | "remove": { 238 | "required": [ 239 | "post" 240 | ], 241 | "method": "POST", 242 | "formats": [ 243 | "json", 244 | "jsonp" 245 | ] 246 | }, 247 | "getContext": { 248 | "required": [ 249 | "post" 250 | ], 251 | "method": "GET", 252 | "formats": [ 253 | "json", 254 | "jsonp", 255 | "rss" 256 | ] 257 | }, 258 | "vote": { 259 | "required": [ 260 | "vote", 261 | "post" 262 | ], 263 | "method": "POST", 264 | "formats": [ 265 | "json", 266 | "jsonp" 267 | ] 268 | }, 269 | "details": { 270 | "required": [ 271 | "post" 272 | ], 273 | "method": "GET", 274 | "formats": [ 275 | "json", 276 | "jsonp" 277 | ] 278 | }, 279 | "report": { 280 | "required": [ 281 | "post" 282 | ], 283 | "method": "POST", 284 | "formats": [ 285 | "json", 286 | "jsonp" 287 | ] 288 | }, 289 | "highlight": { 290 | "required": [ 291 | "post" 292 | ], 293 | "method": "POST", 294 | "formats": [ 295 | "json", 296 | "jsonp" 297 | ] 298 | }, 299 | "approve": { 300 | "required": [ 301 | "post" 302 | ], 303 | "method": "POST", 304 | "formats": [ 305 | "json", 306 | "jsonp" 307 | ] 308 | } 309 | }, 310 | "blacklists": { 311 | "add": { 312 | "required": [ 313 | "forum" 314 | ], 315 | "method": "POST", 316 | "formats": [ 317 | "json", 318 | "jsonp" 319 | ] 320 | }, 321 | "list": { 322 | "required": [ 323 | "forum" 324 | ], 325 | "method": "GET", 326 | "formats": [ 327 | "json", 328 | "jsonp" 329 | ] 330 | }, 331 | "remove": { 332 | "required": [ 333 | "forum" 334 | ], 335 | "method": "POST", 336 | "formats": [ 337 | "json", 338 | "jsonp" 339 | ] 340 | } 341 | }, 342 | "reports": { 343 | "domains": { 344 | "required": [], 345 | "method": "GET", 346 | "formats": [ 347 | "json", 348 | "jsonp" 349 | ] 350 | }, 351 | "ips": { 352 | "required": [], 353 | "method": "GET", 354 | "formats": [ 355 | "json", 356 | "jsonp" 357 | ] 358 | }, 359 | "threads": { 360 | "required": [], 361 | "method": "GET", 362 | "formats": [ 363 | "json", 364 | "jsonp" 365 | ] 366 | }, 367 | "users": { 368 | "required": [], 369 | "method": "GET", 370 | "formats": [ 371 | "json", 372 | "jsonp" 373 | ] 374 | } 375 | }, 376 | "whitelists": { 377 | "add": { 378 | "required": [ 379 | "forum" 380 | ], 381 | "method": "POST", 382 | "formats": [ 383 | "json", 384 | "jsonp" 385 | ] 386 | }, 387 | "list": { 388 | "required": [ 389 | "forum" 390 | ], 391 | "method": "GET", 392 | "formats": [ 393 | "json", 394 | "jsonp" 395 | ] 396 | }, 397 | "remove": { 398 | "required": [ 399 | "forum" 400 | ], 401 | "method": "POST", 402 | "formats": [ 403 | "json", 404 | "jsonp" 405 | ] 406 | } 407 | }, 408 | "applications": { 409 | "listUsage": { 410 | "required": [], 411 | "method": "GET", 412 | "formats": [ 413 | "json", 414 | "jsonp" 415 | ] 416 | } 417 | }, 418 | "trends": { 419 | "listThreads": { 420 | "required": [], 421 | "method": "GET", 422 | "formats": [ 423 | "json", 424 | "jsonp", 425 | "rss" 426 | ] 427 | } 428 | }, 429 | "threads": { 430 | "restore": { 431 | "required": [ 432 | "thread" 433 | ], 434 | "method": "POST", 435 | "formats": [ 436 | "json", 437 | "jsonp" 438 | ] 439 | }, 440 | "vote": { 441 | "required": [ 442 | "vote", 443 | "thread" 444 | ], 445 | "method": "POST", 446 | "formats": [ 447 | "json", 448 | "jsonp" 449 | ] 450 | }, 451 | "open": { 452 | "required": [ 453 | "thread" 454 | ], 455 | "method": "POST", 456 | "formats": [ 457 | "json", 458 | "jsonp" 459 | ] 460 | }, 461 | "create": { 462 | "required": [ 463 | "forum", 464 | "title" 465 | ], 466 | "method": "POST", 467 | "formats": [ 468 | "json", 469 | "jsonp" 470 | ] 471 | }, 472 | "list": { 473 | "required": [], 474 | "method": "GET", 475 | "formats": [ 476 | "json", 477 | "jsonp", 478 | "rss" 479 | ] 480 | }, 481 | "remove": { 482 | "required": [ 483 | "thread" 484 | ], 485 | "method": "POST", 486 | "formats": [ 487 | "json", 488 | "jsonp" 489 | ] 490 | }, 491 | "listHot": { 492 | "required": [], 493 | "method": "GET", 494 | "formats": [ 495 | "json", 496 | "jsonp", 497 | "rss" 498 | ] 499 | }, 500 | "update": { 501 | "required": [ 502 | "thread" 503 | ], 504 | "method": "POST", 505 | "formats": [ 506 | "json", 507 | "jsonp" 508 | ] 509 | }, 510 | "listSimilar": { 511 | "required": [ 512 | "thread" 513 | ], 514 | "method": "GET", 515 | "formats": [ 516 | "json", 517 | "jsonp", 518 | "rss" 519 | ] 520 | }, 521 | "details": { 522 | "required": [ 523 | "thread" 524 | ], 525 | "method": "GET", 526 | "formats": [ 527 | "json", 528 | "jsonp" 529 | ] 530 | }, 531 | "listPosts": { 532 | "required": [ 533 | "thread" 534 | ], 535 | "method": "GET", 536 | "formats": [ 537 | "json", 538 | "jsonp", 539 | "rss" 540 | ] 541 | }, 542 | "close": { 543 | "required": [ 544 | "thread" 545 | ], 546 | "method": "POST", 547 | "formats": [ 548 | "json", 549 | "jsonp" 550 | ] 551 | }, 552 | "listPopular": { 553 | "required": [], 554 | "method": "GET", 555 | "formats": [ 556 | "json", 557 | "jsonp", 558 | "rss" 559 | ] 560 | } 561 | }, 562 | "forums": { 563 | "create": { 564 | "required": [ 565 | "website", 566 | "name", 567 | "short_name" 568 | ], 569 | "method": "POST", 570 | "formats": [ 571 | "json", 572 | "jsonp" 573 | ] 574 | }, 575 | "listCategories": { 576 | "required": [ 577 | "forum" 578 | ], 579 | "method": "GET", 580 | "formats": [ 581 | "json", 582 | "jsonp" 583 | ] 584 | }, 585 | "listThreads": { 586 | "required": [ 587 | "forum" 588 | ], 589 | "method": "GET", 590 | "formats": [ 591 | "json", 592 | "jsonp", 593 | "rss" 594 | ] 595 | }, 596 | "listUsers": { 597 | "required": [ 598 | "forum" 599 | ], 600 | "method": "GET", 601 | "formats": [ 602 | "json", 603 | "jsonp" 604 | ] 605 | }, 606 | "listMostLikedUsers": { 607 | "required": [ 608 | "forum" 609 | ], 610 | "method": "GET", 611 | "formats": [ 612 | "json", 613 | "jsonp" 614 | ] 615 | }, 616 | "details": { 617 | "required": [ 618 | "forum" 619 | ], 620 | "method": "GET", 621 | "formats": [ 622 | "json", 623 | "jsonp" 624 | ] 625 | }, 626 | "listPosts": { 627 | "required": [ 628 | "forum" 629 | ], 630 | "method": "GET", 631 | "formats": [ 632 | "json", 633 | "jsonp", 634 | "rss" 635 | ] 636 | }, 637 | "listModerators": { 638 | "required": [ 639 | "forum" 640 | ], 641 | "method": "GET", 642 | "formats": [ 643 | "json", 644 | "jsonp" 645 | ] 646 | } 647 | }, 648 | "categories": { 649 | "listPosts": { 650 | "required": [ 651 | "category" 652 | ], 653 | "method": "GET", 654 | "formats": [ 655 | "json", 656 | "jsonp", 657 | "rss" 658 | ] 659 | }, 660 | "listThreads": { 661 | "required": [ 662 | "category" 663 | ], 664 | "method": "GET", 665 | "formats": [ 666 | "json", 667 | "jsonp", 668 | "rss" 669 | ] 670 | }, 671 | "create": { 672 | "required": [ 673 | "forum", 674 | "title" 675 | ], 676 | "method": "POST", 677 | "formats": [ 678 | "json", 679 | "jsonp" 680 | ] 681 | }, 682 | "list": { 683 | "required": [], 684 | "method": "GET", 685 | "formats": [ 686 | "json", 687 | "jsonp" 688 | ] 689 | }, 690 | "details": { 691 | "required": [ 692 | "category" 693 | ], 694 | "method": "GET", 695 | "formats": [ 696 | "json", 697 | "jsonp" 698 | ] 699 | } 700 | } 701 | } 702 | -------------------------------------------------------------------------------- /disqusapi/paginator.py: -------------------------------------------------------------------------------- 1 | class Paginator(object): 2 | """ 3 | Paginate through all entries: 4 | 5 | >>> paginator = Paginator(api, 'trends.listThreads', forum='disqus') 6 | >>> for result in paginator: 7 | >>> print result 8 | 9 | Paginate only up to a number of entries: 10 | 11 | >>> for result in paginator(limit=500): 12 | >>> print result 13 | """ 14 | 15 | def __init__(self, *args, **params): 16 | from disqusapi import InterfaceNotDefined 17 | if len(args) == 2: 18 | self.method = args[0] 19 | self.endpoint = args[1] 20 | elif len(args) == 1: 21 | self.method = None 22 | self.endpoint = args[0] 23 | else: 24 | raise InterfaceNotDefined 25 | self.params = params 26 | 27 | def __iter__(self): 28 | for result in self(): 29 | yield result 30 | 31 | def __call__(self, limit=None): 32 | params = self.params.copy() 33 | num = 0 34 | more = True 35 | while more and (not limit or num < limit): 36 | if self.method: 37 | results = self.method(self.endpoint, **params) 38 | else: 39 | results = self.endpoint(**params) 40 | for result in results: 41 | if limit and num >= limit: 42 | break 43 | num += 1 44 | yield result 45 | 46 | if results.cursor: 47 | more = results.cursor['more'] 48 | params['cursor'] = results.cursor['id'] 49 | else: 50 | more = False 51 | -------------------------------------------------------------------------------- /disqusapi/tests.py: -------------------------------------------------------------------------------- 1 | import mock 2 | import os 3 | import socket 4 | 5 | import disqusapi 6 | from disqusapi.compat import xrange 7 | from disqusapi.tests_compat import TestCase 8 | 9 | extra_interface = { 10 | "reserved": { 11 | "global": { 12 | "word": { 13 | "method": "GET", 14 | "required": [ 15 | "text", 16 | ], 17 | "formats": [ 18 | "json", 19 | ], 20 | } 21 | } 22 | } 23 | } 24 | 25 | 26 | extra_interface = { 27 | "reserved": { 28 | "global": { 29 | "word": { 30 | "method": "GET", 31 | "required": [ 32 | "text", 33 | ], 34 | "formats": [ 35 | "json", 36 | ], 37 | } 38 | } 39 | } 40 | } 41 | 42 | 43 | def requires(*env_vars): 44 | def wrapped(func): 45 | for k in env_vars: 46 | if not os.environ.get(k): 47 | return 48 | return func 49 | return wrapped 50 | 51 | 52 | def iter_results(): 53 | for n in xrange(11): 54 | yield disqusapi.Result( 55 | response=[n] * 10, 56 | cursor={ 57 | 'id': n, 58 | 'more': n < 10, 59 | }, 60 | ) 61 | 62 | 63 | class MockResponse(object): 64 | def __init__(self, body, status=200): 65 | self.body = body 66 | self.status = status 67 | 68 | def read(self): 69 | return self.body 70 | 71 | 72 | class DisqusAPITest(TestCase): 73 | API_SECRET = 'b' * 64 74 | API_PUBLIC = 'c' * 64 75 | HOST = os.environ.get('DISQUS_API_HOST', disqusapi.HOST) 76 | 77 | def setUp(self): 78 | disqusapi.HOST = self.HOST 79 | 80 | def test_setKey(self): 81 | api = disqusapi.DisqusAPI('a', 'c') 82 | self.assertEquals(api.secret_key, 'a') 83 | api.setKey('b') 84 | self.assertEquals(api.secret_key, 'b') 85 | 86 | def test_setSecretKey(self): 87 | api = disqusapi.DisqusAPI('a', 'c') 88 | self.assertEquals(api.secret_key, 'a') 89 | api.setSecretKey('b') 90 | self.assertEquals(api.secret_key, 'b') 91 | 92 | def test_setPublicKey(self): 93 | api = disqusapi.DisqusAPI('a', 'c') 94 | self.assertEquals(api.public_key, 'c') 95 | api.setPublicKey('b') 96 | self.assertEquals(api.public_key, 'b') 97 | 98 | def test_setFormat(self): 99 | api = disqusapi.DisqusAPI() 100 | self.assertEquals(api.format, 'json') 101 | api.setFormat('jsonp') 102 | self.assertEquals(api.format, 'jsonp') 103 | 104 | def test_setVersion(self): 105 | api = disqusapi.DisqusAPI() 106 | self.assertEquals(api.version, '3.0') 107 | api.setVersion('3.1') 108 | self.assertEquals(api.version, '3.1') 109 | 110 | def test_setTimeout(self): 111 | api = disqusapi.DisqusAPI() 112 | self.assertEquals(api.timeout, socket.getdefaulttimeout()) 113 | api = disqusapi.DisqusAPI(timeout=30) 114 | self.assertEquals(api.timeout, 30) 115 | api.setTimeout(60) 116 | self.assertEquals(api.timeout, 60) 117 | 118 | def test_paginator(self): 119 | api = disqusapi.DisqusAPI(self.API_SECRET, self.API_PUBLIC) 120 | with mock.patch('disqusapi.Resource._request') as _request: 121 | iterator = iter_results() 122 | _request.return_value = next(iterator) 123 | paginator = disqusapi.Paginator(api, 'posts.list', forum='disqus') 124 | n = 0 125 | for n, result in enumerate(paginator(limit=100)): 126 | if n % 10 == 0: 127 | next(iterator) 128 | self.assertEquals(n, 99) 129 | 130 | def test_paginator_legacy(self): 131 | api = disqusapi.DisqusAPI(self.API_SECRET, self.API_PUBLIC) 132 | with mock.patch('disqusapi.Resource._request') as _request: 133 | iterator = iter_results() 134 | _request.return_value = next(iterator) 135 | paginator = disqusapi.Paginator(api.posts.list, forum='disqus') 136 | n = 0 137 | for n, result in enumerate(paginator(limit=100)): 138 | if n % 10 == 0: 139 | next(iterator) 140 | self.assertEquals(n, 99) 141 | 142 | def test_endpoint(self): 143 | api = disqusapi.DisqusAPI(self.API_SECRET, self.API_PUBLIC) 144 | with mock.patch('disqusapi.Resource._request') as _request: 145 | iterator = iter_results() 146 | _request.return_value = next(iterator) 147 | response1 = api.posts.list(forum='disqus') 148 | 149 | with mock.patch('disqusapi.Resource._request') as _request: 150 | iterator = iter_results() 151 | _request.return_value = next(iterator) 152 | response2 = api.get('posts.list', forum='disqus') 153 | 154 | self.assertEquals(len(response1), len(response2)) 155 | 156 | def test_update_interface_legacy(self): 157 | api = disqusapi.DisqusAPI(self.API_SECRET, self.API_PUBLIC) 158 | with self.assertRaises(disqusapi.InterfaceNotDefined): 159 | api.interface.update(extra_interface) 160 | 161 | def test_invalid_method(self): 162 | api = disqusapi.DisqusAPI(self.API_SECRET, self.API_PUBLIC) 163 | with self.assertRaises(disqusapi.InvalidHTTPMethod): 164 | api.get('posts.list', method='lol', forum='disqus') 165 | 166 | def test_update_interface(self): 167 | api = disqusapi.DisqusAPI(self.API_SECRET, self.API_PUBLIC) 168 | api.update_interface(extra_interface) 169 | 170 | if __name__ == '__main__': 171 | import unittest 172 | unittest.main() 173 | -------------------------------------------------------------------------------- /disqusapi/tests_compat.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import sys 3 | 4 | if sys.version_info < (2, 7): 5 | # Stolen from unittest2 6 | import re 7 | 8 | class _AssertRaisesBaseContext(object): 9 | 10 | def __init__(self, expected, test_case, callable_obj=None, 11 | expected_regex=None): 12 | self.expected = expected 13 | self.failureException = test_case.failureException 14 | if callable_obj is not None: 15 | try: 16 | self.obj_name = callable_obj.__name__ 17 | except AttributeError: 18 | self.obj_name = str(callable_obj) 19 | else: 20 | self.obj_name = None 21 | if isinstance(expected_regex, basestring): # NOQA 22 | expected_regex = re.compile(expected_regex) 23 | self.expected_regex = expected_regex 24 | 25 | class _AssertRaisesContext(_AssertRaisesBaseContext): 26 | """A context manager used to implement TestCase.assertRaises* methods.""" 27 | 28 | def __enter__(self): 29 | return self 30 | 31 | def __exit__(self, exc_type, exc_value, tb): 32 | if exc_type is None: 33 | try: 34 | exc_name = self.expected.__name__ 35 | except AttributeError: 36 | exc_name = str(self.expected) 37 | raise self.failureException( 38 | "%s not raised" % (exc_name,)) 39 | if not issubclass(exc_type, self.expected): 40 | # let unexpected exceptions pass through 41 | return False 42 | self.exception = exc_value # store for later retrieval 43 | if self.expected_regex is None: 44 | return True 45 | 46 | expected_regex = self.expected_regex 47 | if not expected_regex.search(str(exc_value)): 48 | raise self.failureException( 49 | '%r does not match %r' % 50 | (expected_regex.pattern, str(exc_value))) 51 | return True 52 | 53 | class TestCase(unittest.TestCase): 54 | def assertRaises(self, excClass, callableObj=None, *args, **kwargs): 55 | """Fail unless an exception of class excClass is thrown 56 | by callableObj when invoked with arguments args and keyword 57 | arguments kwargs. If a different type of exception is 58 | thrown, it will not be caught, and the test case will be 59 | deemed to have suffered an error, exactly as for an 60 | unexpected exception. 61 | 62 | If called with callableObj omitted or None, will return a 63 | context object used like this:: 64 | 65 | with self.assertRaises(SomeException): 66 | do_something() 67 | 68 | The context manager keeps a reference to the exception as 69 | the 'exception' attribute. This allows you to inspect the 70 | exception after the assertion:: 71 | 72 | with self.assertRaises(SomeException) as cm: 73 | do_something() 74 | the_exception = cm.exception 75 | self.assertEqual(the_exception.error_code, 3) 76 | """ 77 | if callableObj is None: 78 | return _AssertRaisesContext(excClass, self) 79 | try: 80 | callableObj(*args, **kwargs) 81 | except excClass: 82 | return 83 | 84 | if hasattr(excClass, '__name__'): 85 | excName = excClass.__name__ 86 | else: 87 | excName = str(excClass) 88 | raise self.failureException("%s not raised" % excName) 89 | else: 90 | TestCase = unittest.TestCase 91 | -------------------------------------------------------------------------------- /disqusapi/utils.py: -------------------------------------------------------------------------------- 1 | import binascii 2 | import hashlib 3 | import hmac 4 | from disqusapi import compat 5 | from disqusapi.compat import urllib_parse as urlparse 6 | 7 | 8 | def build_interfaces_by_method(interfaces): 9 | """ 10 | Create new dictionary from INTERFACES hashed by method then 11 | the endpoints name. For use when using the disqusapi by the 12 | method interface instead of the endpoint interface. For 13 | instance: 14 | 15 | 'blacklists': { 16 | 'add': { 17 | 'formats': ['json', 'jsonp'], 18 | 'method': 'POST', 19 | 'required': ['forum'] 20 | } 21 | } 22 | 23 | is translated to: 24 | 25 | 'POST': { 26 | 'blacklists.add': { 27 | 'formats': ['json', 'jsonp'], 28 | 'method': 'POST', 29 | 'required': ['forum'] 30 | } 31 | """ 32 | def traverse(block, parts): 33 | try: 34 | method = block['method'].lower() 35 | except KeyError: 36 | for k, v in compat.iteritems(block): 37 | traverse(v, parts + [k]) 38 | else: 39 | path = '.'.join(parts) 40 | try: 41 | methods[method] 42 | except KeyError: 43 | methods[method] = {} 44 | methods[method][path] = block 45 | methods = {} 46 | for key, val in compat.iteritems(interfaces): 47 | traverse(val, [key]) 48 | return methods 49 | 50 | 51 | def get_normalized_params(params): 52 | """ 53 | Given a list of (k, v) parameters, returns 54 | a sorted, encoded normalized param 55 | """ 56 | return urlparse.urlencode(sorted(params)) 57 | 58 | 59 | def get_normalized_request_string(method, url, nonce, params, ext='', body_hash=None): 60 | """ 61 | Returns a normalized request string as described iN OAuth2 MAC spec. 62 | 63 | http://tools.ietf.org/html/draft-ietf-oauth-v2-http-mac-00#section-3.3.1 64 | """ 65 | urlparts = urlparse.urlparse(url) 66 | if urlparts.query: 67 | norm_url = '%s?%s' % (urlparts.path, urlparts.query) 68 | elif params: 69 | norm_url = '%s?%s' % (urlparts.path, get_normalized_params(params)) 70 | else: 71 | norm_url = urlparts.path 72 | 73 | if not body_hash: 74 | body_hash = get_body_hash(params) 75 | 76 | port = urlparts.port 77 | if not port: 78 | assert urlparts.scheme in ('http', 'https') 79 | 80 | if urlparts.scheme == 'http': 81 | port = 80 82 | elif urlparts.scheme == 'https': 83 | port = 443 84 | 85 | output = [nonce, method.upper(), norm_url, urlparts.hostname, port, body_hash, ext, ''] 86 | 87 | return '\n'.join(map(str, output)) 88 | 89 | 90 | def get_body_hash(params): 91 | """ 92 | Returns BASE64 ( HASH (text) ) as described in OAuth2 MAC spec. 93 | 94 | http://tools.ietf.org/html/draft-ietf-oauth-v2-http-mac-00#section-3.2 95 | """ 96 | norm_params = get_normalized_params(params) 97 | 98 | return binascii.b2a_base64(hashlib.sha1(norm_params).digest())[:-1] 99 | 100 | 101 | def get_mac_signature(api_secret, norm_request_string): 102 | """ 103 | Returns HMAC-SHA1 (api secret, normalized request string) 104 | """ 105 | hashed = hmac.new(str(api_secret), norm_request_string, hashlib.sha1) 106 | return binascii.b2a_base64(hashed.digest())[:-1] 107 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [pytest] 2 | python_files = disqusapi/tests.py 3 | 4 | [flake8] 5 | max-line-length = 100 6 | 7 | [wheel] 8 | universal = 1 9 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import setup, find_packages 4 | from setuptools.command.test import test as TestCommand 5 | 6 | 7 | class PyTest(TestCommand): 8 | def finalize_options(self): 9 | TestCommand.finalize_options(self) 10 | self.test_args = [] 11 | self.test_suite = True 12 | 13 | def run_tests(self): 14 | import pytest 15 | import sys 16 | sys.exit(pytest.main(self.test_args)) 17 | 18 | 19 | setup( 20 | name='disqus-python', 21 | version='0.4.2', 22 | author='DISQUS', 23 | author_email='opensource@disqus.com', 24 | url='https://github.com/disqus/disqus-python', 25 | description='Disqus API Bindings', 26 | packages=find_packages(), 27 | zip_safe=False, 28 | license='Apache License 2.0', 29 | install_requires=[], 30 | setup_requires=[], 31 | tests_require=[ 32 | 'pytest', 33 | 'mock', 34 | ], 35 | cmdclass={'test': PyTest}, 36 | include_package_data=True, 37 | classifiers=[ 38 | 'Intended Audience :: Developers', 39 | 'Intended Audience :: System Administrators', 40 | 'Operating System :: OS Independent', 41 | 'Programming Language :: Python :: 2', 42 | 'Programming Language :: Python :: 2.6', 43 | 'Programming Language :: Python :: 2.7', 44 | 'Programming Language :: Python :: 3', 45 | 'Programming Language :: Python :: 3.2', 46 | 'Programming Language :: Python :: 3.3', 47 | 'Programming Language :: Python :: 3.4', 48 | 'Programming Language :: Python', 49 | 'Topic :: Software Development', 50 | ], 51 | ) 52 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox (http://tox.testrun.org/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | 6 | [tox] 7 | envlist = py26, py27, py32, py33, py34, pypy 8 | 9 | [testenv] 10 | commands = {envpython} setup.py test 11 | deps = 12 | --------------------------------------------------------------------------------