├── .gitignore ├── LICENSE ├── README.rst ├── examples.py ├── humblebundle ├── __init__.py ├── client.py ├── decorators.py ├── exceptions.py ├── handlers.py └── models.py ├── setup.cfg └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | ### Python 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | env/ 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # Installer logs 27 | pip-log.txt 28 | pip-delete-this-directory.txt 29 | 30 | # Unit test / coverage reports 31 | htmlcov/ 32 | .tox/ 33 | .coverage 34 | .cache 35 | nosetests.xml 36 | coverage.xml 37 | 38 | # Translations 39 | *.mo 40 | *.pot 41 | 42 | # Django stuff: 43 | *.log 44 | 45 | # Sphinx documentation 46 | docs/_build/ 47 | 48 | ### VIM 49 | 50 | [._]*.s[a-w][a-z] 51 | [._]s[a-w][a-z] 52 | *.un~ 53 | Session.vim 54 | .netrwhist 55 | *~ 56 | 57 | ### Emacs 58 | 59 | # -*- mode: gitignore; -*- 60 | *~ 61 | \#*\# 62 | /.emacs.desktop 63 | /.emacs.desktop.lock 64 | *.elc 65 | auto-save-list 66 | tramp 67 | .\#* 68 | 69 | # Org-mode 70 | .org-id-locations 71 | *_archive 72 | 73 | # flymake-mode 74 | *_flymake.* 75 | 76 | # eshell files 77 | /eshell/history 78 | /eshell/lastdir 79 | 80 | # elpa packages 81 | /elpa/ 82 | 83 | # reftex files 84 | *.rel 85 | 86 | # AUCTeX auto folder 87 | /auto/ 88 | 89 | ### Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm 90 | 91 | ## Directory-based project format 92 | .idea/ 93 | # if you remove the above rule, at least ignore user-specific stuff: 94 | # .idea/workspace.xml 95 | # .idea/tasks.xml 96 | # and these sensitive or high-churn files: 97 | # .idea/dataSources.ids 98 | # .idea/dataSources.xml 99 | # .idea/sqlDataSources.xml 100 | # .idea/dynamic.xml 101 | 102 | ## File-based project format 103 | *.ipr 104 | *.iml 105 | *.iws 106 | 107 | ## Additional for IntelliJ 108 | out/ 109 | 110 | # generated by mpeltonen/sbt-idea plugin 111 | .idea_modules/ 112 | 113 | # generated by JIRA plugin 114 | atlassian-ide-plugin.xml 115 | 116 | # generated by Crashlytics plugin (for Android Studio and Intellij) 117 | com_crashlytics_export_strings.xml 118 | 119 | ### IPython 120 | 121 | # Temporary data 122 | .ipynb_checkpoints/ 123 | 124 | ### Linux 125 | 126 | *~ 127 | 128 | # KDE directory preferences 129 | .directory 130 | 131 | ### Windows 132 | 133 | # Windows image file caches 134 | Thumbs.db 135 | ehthumbs.db 136 | 137 | # Folder config file 138 | Desktop.ini 139 | 140 | # Recycle Bin used on file shares 141 | $RECYCLE.BIN/ 142 | 143 | # Windows Installer files 144 | *.cab 145 | *.msi 146 | *.msm 147 | *.msp 148 | 149 | ### Mac OS X 150 | 151 | .DS_Store 152 | .AppleDouble 153 | .LSOverride 154 | 155 | # Icon must end with two \r 156 | Icon 157 | 158 | 159 | # Thumbnails 160 | ._* 161 | 162 | # Files that might appear on external disk 163 | .Spotlight-V100 164 | .Trashes 165 | 166 | # Directories potentially created on remote AFP share 167 | .AppleDB 168 | .AppleDesktop 169 | Network Trash Folder 170 | Temporary Items 171 | .apdisk -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Joel Pedraza 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | humblebundle-python 2 | =================== 3 | 4 | humblebundle-python is an unofficial library for querying the undocumented Humble Bundle API. 5 | 6 | 7 | Usage 8 | ----- 9 | Basic usage looks something like:: 10 | 11 | client = humblebundle.HumbleApi() 12 | 13 | client.search_store("ftl") 14 | 15 | client.login("username@example.com", "secret") 16 | 17 | gamekeys = client.get_gamekeys() 18 | 19 | for gamekey in gamekeys: 20 | order = client.get_order(gamekey) 21 | for subproduct in order.subproducts: 22 | print(subproduct) 23 | 24 | See examples.py for more examples 25 | 26 | humblebundle-python is not affiliated in any way with Humble Bundle, Inc. 27 | -------------------------------------------------------------------------------- /examples.py: -------------------------------------------------------------------------------- 1 | __author__ = "Joel Pedraza" 2 | __copyright__ = "Copyright 2014, Joel Pedraza" 3 | __license__ = "MIT" 4 | 5 | import humblebundle 6 | 7 | # Start a new session. 8 | # It's not a RESTful api, we must store an auth token in out session cookies. 9 | client = humblebundle.HumbleApi() 10 | 11 | # Login and store the auth cookie in the session 12 | client.login("username@example.com", "secret") 13 | 14 | # Download the list of orders for the current user 15 | order_list = client.order_list() 16 | 17 | # Download the subproduct listing for each order 18 | # An order has information about a purchase, but not its contents 19 | for order in order_list: 20 | # Fill in the subproducts (the titles purchased in the order) 21 | order.ensure_subproducts() 22 | # Print every game, ebook, audiobook, etc. for each order 23 | for subproduct in order.subproducts: 24 | print(subproduct.machine_name) 25 | 26 | -------------------------------------------------------------------------------- /humblebundle/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = "Joel Pedraza" 2 | __copyright__ = "Copyright 2014, Joel Pedraza" 3 | __license__ = "MIT" 4 | __all__ = ['HumbleApi', 'logger'] 5 | 6 | import logging 7 | 8 | logger = logging.getLogger(__name__) 9 | logger.addHandler(logging.NullHandler()) 10 | 11 | from humblebundle.client import HumbleApi -------------------------------------------------------------------------------- /humblebundle/client.py: -------------------------------------------------------------------------------- 1 | """ 2 | The Humble Bundle API client 3 | """ 4 | 5 | __author__ = "Joel Pedraza" 6 | __copyright__ = "Copyright 2014, Joel Pedraza" 7 | __license__ = "MIT" 8 | 9 | __all__ = ['HumbleApi', 'LOGIN_URL', 'ORDER_LIST_URL', 'CLAIMED_ENTITIES_URL', 'SIGNED_DOWNLOAD_URL', 'STORE_URL', 10 | 'logger'] 11 | 12 | from logging import getLogger, NullHandler 13 | 14 | import requests 15 | 16 | from humblebundle.decorators import callback, deprecated 17 | from humblebundle.exceptions import * 18 | import humblebundle.handlers as handlers 19 | 20 | 21 | LOGIN_URL = 'https://www.humblebundle.com/login' 22 | ORDER_LIST_URL = 'https://www.humblebundle.com/api/v1/user/order' 23 | ORDER_URL = 'https://www.humblebundle.com/api/v1/order/{order_id}' 24 | CLAIMED_ENTITIES_URL = 'https://www.humblebundle.com/api/v1/user/claimed/entities' 25 | SIGNED_DOWNLOAD_URL = 'https://www.humblebundle.com/api/v1/user/Download/{machine_name}/sign' 26 | STORE_URL = 'https://www.humblebundle.com/store/api/humblebundle' 27 | 28 | 29 | class HumbleApi(object): 30 | """ 31 | The Humble Bundle API is not stateless, it stores an authentication token as a cookie named _simpleauth_sess 32 | 33 | The Requests.Session handles storing the auth token. To load some persisted cookies simply set session.cookies after 34 | initialization 35 | """ 36 | 37 | """ 38 | Notes: 39 | ====== 40 | 41 | * The API itself is very inconsistent, for example the error syntax varies between API calls 42 | 43 | * The response should always contain valid JSON when the ajax param is set to true. It occasionally breaks this 44 | contract and returns no body. Grr! 45 | 46 | * Because of these two issues, we have separate handlers for each API call. See humblebundle.handlers 47 | 48 | """ 49 | 50 | default_headers = {'Accept': 'application/json', 'Accept-Charset': 'utf-8', 'Keep-Alive': 'true', 51 | 'X-Requested-By': 'hb_android_app', 'User-Agent': 'Apache-HttpClient/UNAVAILABLE (java 1.4)'} 52 | default_params = {'ajax': 'true'} 53 | store_default_params = {"request": 1, "page_size": 20, "sort": "bestselling", "page": 0, "search": None} 54 | 55 | def __init__(self): 56 | self.logger = getLogger(__name__) 57 | self.logger.addHandler(NullHandler()) 58 | 59 | self.session = requests.Session() 60 | self.session.headers.update(self.default_headers) 61 | self.session.params.update(self.default_params) 62 | 63 | """ 64 | API call methods 65 | ================ 66 | 67 | Delegate to handlers to validate response, catch and raise exceptions, and convert response to model 68 | 69 | Unimplemented: 70 | * https://www.humblebundle.com/signup?ajax=true 71 | * https://www.humblebundle.com/user/unclaimed_orders?ajax=true 72 | * https://www.humblebundle.com/bundle/claim?ajax=true 73 | * https://www.humblebundle.com/api/v1/model/(SubProduct|ModelPointer) 74 | - Lots of references to /v1/model/* in the claimed entities response, not sure what they are. 75 | """ 76 | 77 | @callback 78 | def login(self, username, password, authy_token=None, recaptcha_challenge=None, recaptcha_response=None, 79 | *args, **kwargs): 80 | """ 81 | Login to the Humble Bundle API. The response sets the _simpleauth_sess cookie which is stored in the session 82 | automatically. 83 | 84 | :param str username: The user account to authenticate with 85 | :param str password: The password to authenticate with 86 | :param authy_token: (optional) The GoogleAuthenticator/Authy token (One time pass) 87 | :type authy_token: integer or str 88 | :param str recaptcha_challenge: (optional) The challenge signed by Humble Bundle's public key from reCAPTCHA 89 | :param str recaptcha_response: (optional) The plaintext solved CAPTCHA 90 | :param list args: (optional) Extra positional args to pass to the request 91 | :param dict kwargs: (optional) Extra keyword args to pass to the request. If a data dict is supplied a key 92 | collision with any of the above params will resolved in favor of the supplied param 93 | :return: A Future object encapsulating the login request 94 | :rtype: Future 95 | :raises RequestException: if the connection failed 96 | :raises HumbleResponseException: if the response was invalid 97 | :raises HumbleCredentialException: if the username and password did not match 98 | :raises HumbleCaptchaException: if the captcha challenge failed 99 | :raises HumbleTwoFactorException: if the two-factor authentication challenge failed 100 | :raises HumbleAuthenticationException: if some other authentication error occurred 101 | 102 | """ 103 | 104 | self.logger.info("Logging in") 105 | 106 | default_data = { 107 | 'username': username, 108 | 'password': password, 109 | 'authy-token': authy_token, 110 | 'recaptcha_challenge_field': recaptcha_challenge, 111 | 'recaptcha_response_field': recaptcha_response} 112 | kwargs.setdefault('data', {}).update({k: v for k, v in default_data.items() if v is not None}) 113 | 114 | response = self._request('POST', LOGIN_URL, *args, **kwargs) 115 | return handlers.login_handler(self, response) 116 | 117 | @callback 118 | def get_gamekeys(self, *args, **kwargs): 119 | """ 120 | Fetch all the gamekeys owned by an account. 121 | 122 | A gamekey is a string that uniquely identifies an order from the humble store. 123 | 124 | :param list args: (optional) Extra positional args to pass to the request 125 | :param dict kwargs: (optional) Extra keyword args to pass to the request 126 | :return: A list of gamekeys 127 | :rtype: list 128 | :raises RequestException: if the connection failed 129 | :raises HumbleAuthenticationException: if not logged in 130 | :raises HumbleResponseException: if the response was invalid 131 | """ 132 | 133 | self.logger.info("Downloading gamekeys") 134 | response = self._request('GET', ORDER_LIST_URL, *args, **kwargs) 135 | return handlers.gamekeys_handler(self, response) 136 | 137 | @callback 138 | def get_order(self, order_id, *args, **kwargs): 139 | """ 140 | Download an order by it's id 141 | 142 | :param order_id: The identifier ("gamekey") that uniquely identifies the order 143 | :param list args: (optional) Extra positional args to pass to the request 144 | :param dict kwargs: (optional) Extra keyword args to pass to the request 145 | :return: The :py:class:`Order` requested 146 | :rtype: Order 147 | :raises RequestException: if the connection failed 148 | :raises HumbleAuthenticationException: if not logged in 149 | :raises HumbleResponseException: if the response was invalid 150 | """ 151 | self.logger.info("Getting order %s", order_id) 152 | url = ORDER_URL.format(order_id=order_id) 153 | response = self._request('GET', url, *args, **kwargs) 154 | return handlers.order_handler(self, response) 155 | 156 | # TODO: model the claimed_entities response 157 | @callback 158 | def get_claimed_entities(self, platform=None, *args, **kwargs): 159 | """ 160 | Download all the claimed entities for a user 161 | 162 | This call can take a long time for the server to start responding as it has to collect a lot of data about 163 | the user's purchases. 164 | 165 | This method does not parse the result into a subclass of BaseModel, but instead returns the decoded json. 166 | I'm lazy and this just isn't very useful for the client this lib was written for. 167 | 168 | :param platform: 169 | :param list args: (optional) Extra positional args to pass to the request 170 | :param dict kwargs: (optional) Extra keyword args to pass to the request 171 | :return: The parsed json response 172 | :rtype: dict 173 | :raises RequestException: if the connection failed 174 | :raises HumbleAuthenticationException: if not logged in 175 | :raises HumbleResponseException: if the response was invalid 176 | """ 177 | self.logger.info("Downloading claimed entities") 178 | 179 | if platform: 180 | if platform in ('android', 'audio', 'ebook', 'linux', 'mac', 'windows'): 181 | kwargs.setdefault('params', {})['platform'] = platform 182 | else: 183 | raise HumbleException("Unsupported platform: {}".format(platform)) 184 | 185 | kwargs.setdefault('timeout', 60) # This call takes forever 186 | response = self._request('GET', CLAIMED_ENTITIES_URL, *args, **kwargs) 187 | return handlers.claimed_entities_handler(self, response) 188 | 189 | @callback 190 | def sign_download_url(self, machine_name, *args, **kwargs): 191 | """ 192 | Get a download URL by specifying the machine name of a :py:class:`Subproduct` 193 | 194 | Unfortunately it always returns the first download in the download list. This makes it pretty useless for most 195 | platforms. 196 | 197 | :param machine_name: 198 | :param list args: (optional) Extra positional args to pass to the request 199 | :param dict kwargs: (optional) Extra keyword args to pass to the request 200 | :return: The signed url 201 | :rtype: str 202 | :raises RequestException: if the connection failed 203 | :raises HumbleAuthenticationException: if not logged in 204 | :raises HumbleResponseException: if the response was invalid 205 | """ 206 | self.logger.info("Signing download url for %s", machine_name) 207 | url = SIGNED_DOWNLOAD_URL.format(machine_name=machine_name) 208 | response = self._request('GET', url, *args, **kwargs) 209 | return handlers.sign_download_url_handler(self, response) 210 | 211 | @callback 212 | def search_store(self, search_query, *args, **kwargs): 213 | """ 214 | Download a list of the results from the query. 215 | 216 | :param search_query: 217 | :param list args: (optional) Extra positional args to pass to the request 218 | :param dict kwargs: (optional) Extra keyword args to pass to the request 219 | :return: The results 220 | :rtype: list 221 | :raises RequestException: if the connection failed 222 | :raises HumbleResponseException: if the response was invalid 223 | """ 224 | self.logger.info("Searching store for url for {search_query}".format(search_query=search_query)) 225 | url = STORE_URL 226 | 227 | # setup query string parameters 228 | params = self.store_default_params.copy() 229 | params['search'] = search_query 230 | 231 | kwargs_params = kwargs.get('params', {}) if kwargs.get('params') else {} # make sure kwargs['params'] is a dict 232 | 233 | kwargs_params.update(params) # pull in any params in to kwargs 234 | kwargs['params'] = kwargs_params 235 | 236 | response = self._request('GET', url, *args, **kwargs) 237 | self.store_default_params['request'] += 1 # may need to loop after a while 238 | 239 | return handlers.store_products_handler(self, response) 240 | 241 | # Deprecated methods 242 | 243 | @deprecated 244 | def order_list(self, *args, **kwargs): 245 | """ 246 | The api has changed and no longer returns a list of orders in one request. In order to maintain compatibility 247 | with exiting clients this method will make many requests. It is recommended to iterate the results of 248 | :py:func:`self.gamekeys` so exceptions can be handled per request. 249 | 250 | Download a list of all the :py:class:`Order`s made by a user. 251 | 252 | :param list args: (optional) Extra positional args to pass to the request 253 | :param dict kwargs: (optional) Extra keyword args to pass to the request 254 | :return: A list of :py:class:`Order`s 255 | :rtype: list 256 | :raises RequestException: if the connection failed 257 | :raises HumbleAuthenticationException: if not logged in 258 | :raises HumbleResponseException: if the response was invalid 259 | """ 260 | self.logger.info("Downloading order list") 261 | orders = [] 262 | gamekeys = self.get_gamekeys() 263 | 264 | for gamekey in gamekeys: 265 | orders.append(self.order(gamekey)) 266 | 267 | return orders 268 | 269 | @deprecated 270 | def order(self, order_id, *args, **kwargs): 271 | """ 272 | This method is deprecated, it calls through to it's new name :py:func:`self.get_order` 273 | 274 | Download an order by it's id 275 | 276 | :param order_id: The identifier ("gamekey") that uniquely identifies the order 277 | :param list args: (optional) Extra positional args to pass to the request 278 | :param dict kwargs: (optional) Extra keyword args to pass to the request 279 | :return: The :py:class:`Order` requested 280 | :rtype: Order 281 | :raises RequestException: if the connection failed 282 | :raises HumbleAuthenticationException: if not logged in 283 | :raises HumbleResponseException: if the response was invalid 284 | """ 285 | return self.get_order(order_id, *args, **kwargs) 286 | 287 | @deprecated 288 | def get_claimed_entities(self, platform=None, *args, **kwargs): 289 | """ 290 | Download all the claimed entities for a user 291 | 292 | This call can take a long time for the server to start responding as it has to collect a lot of data about 293 | the user's purchases. 294 | 295 | This method does not parse the result into a subclass of BaseModel, but instead returns the decoded json. 296 | I'm lazy and this just isn't very useful for the client this lib was written for. 297 | 298 | :param platform: 299 | :param list args: (optional) Extra positional args to pass to the request 300 | :param dict kwargs: (optional) Extra keyword args to pass to the request 301 | :return: The parsed json response 302 | :rtype: dict 303 | :raises RequestException: if the connection failed 304 | :raises HumbleAuthenticationException: if not logged in 305 | :raises HumbleResponseException: if the response was invalid 306 | """ 307 | return self.get_claimed_entities(platform=platform, *args, **kwargs) 308 | 309 | # Internal helper methods 310 | 311 | def _request(self, *args, **kwargs): 312 | """ 313 | Set sane defaults that aren't session wide. Otherwise maintains the api of Session.request 314 | """ 315 | 316 | kwargs.setdefault('timeout', 30) 317 | return self.session.request(*args, **kwargs) -------------------------------------------------------------------------------- /humblebundle/decorators.py: -------------------------------------------------------------------------------- 1 | __author__ = "Joel Pedraza" 2 | __copyright__ = "Copyright 2014, Joel Pedraza" 3 | __license__ = "MIT" 4 | 5 | from humblebundle import logger 6 | 7 | 8 | def callback(func): 9 | """ 10 | A decorator to add a keyword arg 'callback' to execute a method on the return value of a function 11 | 12 | Used to add callbacks to the API calls 13 | 14 | :param func: The function to decorate 15 | :return: The wrapped function 16 | """ 17 | 18 | def wrap(*args, **kwargs): 19 | callback_ = kwargs.pop('callback', None) 20 | result = func(*args, **kwargs) 21 | if callback_: 22 | callback_(result) 23 | return result 24 | 25 | return wrap 26 | 27 | 28 | def deprecated(func): 29 | """ 30 | A decorator which can be used to mark functions as deprecated. It will result in a warning being emitted 31 | to the module level logger when the function is used. 32 | :param func: The deprecated function 33 | :return: The wrapped function 34 | """ 35 | 36 | def wrap(*args, **kwargs): 37 | logger.warn("Call to deprecated function {}.".format(func.__name__)) 38 | return func(*args, **kwargs) 39 | 40 | return wrap -------------------------------------------------------------------------------- /humblebundle/exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Exception classes for the Humble Bundle API 3 | 4 | This module only is guaranteed to only contain exception class definitions 5 | """ 6 | 7 | __author__ = "Joel Pedraza" 8 | __copyright__ = "Copyright 2014, Joel Pedraza" 9 | __license__ = "MIT" 10 | 11 | __all__ = ['HumbleException', 'HumbleResponseException', 'HumbleAuthenticationException', 'HumbleCredentialException', 12 | 'HumbleCaptchaException', 'HumbleTwoFactorException', 'HumbleParseException'] 13 | 14 | from requests import RequestException 15 | 16 | 17 | class HumbleException(Exception): 18 | """ 19 | An unspecified error occurred 20 | """ 21 | pass 22 | 23 | 24 | class HumbleResponseException(RequestException, HumbleException): 25 | """ 26 | A Request completed but the response was somehow invalid or unexpected 27 | """ 28 | 29 | def __init__(self, *args, **kwargs): 30 | super(HumbleResponseException, self).__init__(*args, **kwargs) 31 | 32 | 33 | class HumbleAuthenticationException(HumbleResponseException): 34 | """ 35 | An unspecified authentication failure occurred 36 | """ 37 | 38 | def __init__(self, *args, **kwargs): 39 | self.captcha_required = kwargs.pop('captcha_required', None) 40 | self.authy_required = kwargs.pop('authy_required', None) 41 | super(HumbleAuthenticationException, self).__init__(*args, **kwargs) 42 | 43 | 44 | class HumbleCredentialException(HumbleAuthenticationException): 45 | """ 46 | Username and password don't match 47 | """ 48 | pass 49 | 50 | 51 | class HumbleCaptchaException(HumbleAuthenticationException): 52 | """ 53 | The CAPTCHA response was invalid 54 | """ 55 | pass 56 | 57 | 58 | class HumbleTwoFactorException(HumbleAuthenticationException): 59 | """ 60 | The one time password was invalid 61 | """ 62 | pass 63 | 64 | 65 | class HumbleParseException(HumbleResponseException): 66 | """ 67 | An error occurred while parsing 68 | """ 69 | pass -------------------------------------------------------------------------------- /humblebundle/handlers.py: -------------------------------------------------------------------------------- 1 | """ 2 | Handlers to process the responses from the Humble Bundle API 3 | """ 4 | 5 | __author__ = "Joel Pedraza" 6 | __copyright__ = "Copyright 2014, Joel Pedraza" 7 | __license__ = "MIT" 8 | 9 | import itertools 10 | 11 | import requests 12 | 13 | from humblebundle.exceptions import * 14 | from humblebundle.models import * 15 | 16 | 17 | # Helper methods 18 | 19 | def parse_data(response): 20 | try: 21 | return response.json() 22 | except ValueError as e: 23 | raise HumbleParseException("Invalid JSON: %s", str(e), request=response.request, response=response) 24 | 25 | 26 | def get_errors(data): 27 | errors = data.get('errors', None) 28 | error_msg = ", ".join(itertools.chain.from_iterable(v for k, v in errors.items())) \ 29 | if errors else "Unspecified error" 30 | return errors, error_msg 31 | 32 | 33 | def authenticated_response_helper(response, data): 34 | # Successful API calls might not have a success property. 35 | # It's not enough to check if it's falsy, as None is acceptable 36 | 37 | success = data.get('success', None) 38 | if success is True: 39 | return True 40 | 41 | error_id = data.get('error_id', None) 42 | errors, error_msg = get_errors(data) 43 | 44 | # API calls that require login and have a missing or invalid token 45 | if error_id == 'login_required': 46 | raise HumbleAuthenticationException(error_msg, request=response.request, response=response) 47 | 48 | # Something happened, we're not sure what but we hope the error_msg is useful 49 | if success is False or errors is not None or error_id is not None: 50 | raise HumbleResponseException(error_msg, request=response.request, response=response) 51 | 52 | # Response had no success or errors fields, it's probably data 53 | return True 54 | 55 | 56 | # Response handlers 57 | 58 | 59 | def login_handler(client, response): 60 | """ login response always returns JSON """ 61 | 62 | data = parse_data(response) 63 | 64 | success = data.get('success', None) 65 | if success is True: 66 | return True 67 | 68 | captcha_required = data.get('captcha_required') 69 | authy_required = data.get('authy_required') 70 | 71 | errors, error_msg = get_errors(data) 72 | if errors: 73 | captcha = errors.get('captcha') 74 | if captcha: 75 | raise HumbleCaptchaException(error_msg, request=response.request, response=response, 76 | captcha_required=captcha_required, authy_required=authy_required) 77 | 78 | username = errors.get('username') 79 | if username: 80 | raise HumbleCredentialException(error_msg, request=response.request, response=response, 81 | captcha_required=captcha_required, authy_required=authy_required) 82 | 83 | authy_token = errors.get("authy-token") 84 | if authy_token: 85 | raise HumbleTwoFactorException(error_msg, request=response.request, response=response, 86 | captcha_required=captcha_required, authy_required=authy_required) 87 | 88 | raise HumbleAuthenticationException(error_msg, request=response.request, response=response, 89 | captcha_required=captcha_required, authy_required=authy_required) 90 | 91 | 92 | def gamekeys_handler(client, response): 93 | """ get_gamekeys response always returns JSON """ 94 | 95 | data = parse_data(response) 96 | 97 | if isinstance(data, list): 98 | return [v['gamekey'] for v in data] 99 | 100 | # Let the helper function raise any common exceptions 101 | authenticated_response_helper(response, data) 102 | 103 | # We didn't get a list, or an error message 104 | raise HumbleResponseException("Unexpected response body", request=response.request, response=response) 105 | 106 | 107 | def order_list_handler(client, response): 108 | """ order_list response always returns JSON """ 109 | 110 | data = parse_data(response) 111 | 112 | if isinstance(data, list): 113 | return [Order(client, order) for order in data] 114 | 115 | # Let the helper function raise any common exceptions 116 | authenticated_response_helper(response, data) 117 | 118 | # We didn't get a list, or an error message 119 | raise HumbleResponseException("Unexpected response body", request=response.request, response=response) 120 | 121 | 122 | def order_handler(client, response): 123 | """ order response might be 404 with no body if not found """ 124 | 125 | if response.status_code == requests.codes.not_found: 126 | raise HumbleResponseException("Order not found", request=response.request, response=response) 127 | 128 | data = parse_data(response) 129 | 130 | # The helper function should be sufficient to catch any other errors 131 | if authenticated_response_helper(response, data): 132 | return Order(client, data) 133 | 134 | 135 | def claimed_entities_handler(client, response): 136 | """ 137 | claimed_entities response always returns JSON 138 | returns parsed json dict 139 | """ 140 | 141 | data = parse_data(response) 142 | 143 | # The helper function should be sufficient to catch any errors 144 | if authenticated_response_helper(response, data): 145 | return data 146 | 147 | 148 | def sign_download_url_handler(client, response): 149 | """ sign_download_url response always returns JSON """ 150 | 151 | data = parse_data(response) 152 | 153 | # If the request is unauthorized (this includes invalid machine names) this response has it's own error syntax 154 | errors = data.get('_errors', None) 155 | message = data.get('_message', None) 156 | 157 | if errors: 158 | error_msg = "%s: %s" % (errors, message) 159 | raise HumbleResponseException(error_msg, request=response.request, response=response) 160 | 161 | # If the user isn't signed in we get a "typical" error response 162 | if authenticated_response_helper(response, data): 163 | return data['signed_url'] 164 | 165 | 166 | def store_products_handler(client, response): 167 | """ Takes a results from the store as JSON and converts it to object """ 168 | 169 | data = parse_data(response) 170 | return [StoreProduct(client, result) for result in data['results']] 171 | -------------------------------------------------------------------------------- /humblebundle/models.py: -------------------------------------------------------------------------------- 1 | """ 2 | Model classes for the Humble Bundle API 3 | 4 | This module only is guaranteed to only contain model class definitions 5 | """ 6 | 7 | __author__ = "Joel Pedraza" 8 | __copyright__ = "Copyright 2014, Joel Pedraza" 9 | __license__ = "MIT" 10 | 11 | 12 | class BaseModel(object): 13 | def __init__(self, client, data): 14 | self._client = client 15 | 16 | def __str__(self): 17 | return str({key: self.__dict__[key] for key in self.__dict__ if key != '_client'}) 18 | 19 | def __repr__(self): 20 | return repr(self.__dict__) 21 | 22 | def __iter__(self): 23 | return self.__dict__.__iter__() 24 | 25 | 26 | class Order(BaseModel): 27 | def __init__(self, client, data): 28 | super(Order, self).__init__(client, data) 29 | self.product = Product(client, data['product']) 30 | subscriptions = data.get('subscriptions', []) 31 | self.subscriptions = [Subscription(client, sub) for sub in subscriptions] if len(subscriptions) > 0 else None 32 | self.thankname = data.get('thankname', None) 33 | self.claimed = data.get('claimed', None) 34 | self.gamekey = data.get('gamekey', None) 35 | self.country = data.get('country', None) 36 | self.giftee = data.get('giftee', None) 37 | self.leaderboard = data.get('leaderboard', None) 38 | self.owner_username = data.get('owner_username', None) 39 | self.platform = data.get('platform', None) 40 | self.subproducts = ([Subproduct(client, prod) for prod in data.get('subproducts', [])]) or None 41 | 42 | def __repr__(self): 43 | return "Order: <%s>" % self.product.machine_name 44 | 45 | 46 | class Product(BaseModel): 47 | def __init__(self, client, data): 48 | super(Product, self).__init__(client, data) 49 | self.category = data.get(1, None) 50 | self.human_name = data['human_name'] 51 | self.machine_name = data['machine_name'] 52 | self.supports_canonical = data['supports_canonical'] 53 | 54 | def __repr__(self): 55 | return "Product: <%s>" % self.machine_name 56 | 57 | 58 | class StoreProduct(BaseModel): 59 | def __init__(self, client, data): 60 | super(StoreProduct, self).__init__(client, data) 61 | self.category = data.get(1, None) 62 | self.human_name = data['human_name'] 63 | self.machine_name = data['machine_name'] 64 | self.current_price = Price(client, data['current_price']) 65 | self.full_price = Price(client, data['full_price']) 66 | self.icon = data['storefront_icon'] # URL as string 67 | self.platforms = data['platforms'] # linux, windows, mac 68 | self.delivery_methods = data['delivery_methods'] # download, steam, origin 69 | self.description = data['description'] # HTML 70 | self.content_types = data['content_types'] 71 | self.youtube_id = data['youtube_link'] # ID of youtube video 72 | self.esrb_rating = data['esrb_rating'] 73 | self.pegi_rating = data['pegi_rating'] 74 | self.developers = data['developers'] # dictionary 75 | self.publishers = data['publishers'] 76 | self.allowed_territories = data['allowed_territories'] 77 | self.minimum_age = data['minimum_age'] 78 | self.system_requirements = data['system_requirements'] # HTML 79 | 80 | def __repr__(self): 81 | return "StoreProduct: <%s>" % self.machine_name 82 | 83 | 84 | class Subscription(BaseModel): 85 | def __init__(self, client, data): 86 | super(Subscription, self).__init__(client, data) 87 | self.human_name = data['human_name'] 88 | self.list_name = data['list_name'] 89 | self.subscribed = data['subscribed'] 90 | 91 | def __repr__(self): 92 | return "Subscription: <%s : %s>" % (self.list_name, self.subscribed) 93 | 94 | 95 | class Subproduct(BaseModel): 96 | def __init__(self, client, data): 97 | super(Subproduct, self).__init__(client, data) 98 | self.machine_name = data['machine_name'] 99 | self.payee = Payee(client, data['payee']) 100 | self.url = data['url'] 101 | self.downloads = [Download(client, download) for download in data['downloads']] 102 | self.human_name = data['human_name'] 103 | self.custom_download_page_box_html = data['custom_download_page_box_html'] 104 | self.icon = data['icon'] 105 | 106 | def __repr__(self): 107 | return "Subproduct: <%s>" % self.machine_name 108 | 109 | 110 | class Payee(BaseModel): 111 | def __init__(self, client, data): 112 | super(Payee, self).__init__(client, data) 113 | self.human_name = data['human_name'] 114 | self.machine_name = data['machine_name'] 115 | 116 | def __repr__(self): 117 | return "Payee: <%s>" % self.machine_name 118 | 119 | 120 | class Download(BaseModel): 121 | def __init__(self, client, data): 122 | super(Download, self).__init__(client, data) 123 | self.machine_name = data['machine_name'] 124 | self.platform = data['platform'] 125 | self.download_struct = [DownloadStruct(client, struct) for struct in data['download_struct']] 126 | self.options_dict = data['options_dict'] 127 | self.download_identifier = data['download_identifier'] 128 | self.download_version_number = data['download_version_number'] 129 | 130 | def sign_download_url(self, *args, **kwargs): 131 | return self._client.sign_download_url(self.machine_name, *args, **kwargs) 132 | 133 | def __repr__(self): 134 | return "Download: <%s>" % self.machine_name 135 | 136 | 137 | class DownloadStruct(BaseModel): 138 | def __init__(self, client, data): 139 | super(DownloadStruct, self).__init__(client, data) 140 | self.sha1 = data.get('sha1', None) 141 | self.name = data.get('name', None) 142 | self.message = data.get('message', None) 143 | self.url = Url(client, data.get('url', {})) 144 | self.external_link = data.get('external_link', None) 145 | self.recommend_bittorrent = data.get('recommend_bittorrent', None) 146 | self.human_size = data.get('human_size', None) 147 | self.file_size = data.get('file_size', None) 148 | self.md5 = data.get('md5', None) 149 | self.fat32_warning = data.get('fat32_warning', None) 150 | self.size = data.get('size', None) 151 | self.small = data.get('small', None) 152 | 153 | 154 | class Url(BaseModel): 155 | def __init__(self, client, data): 156 | super(Url, self).__init__(client, data) 157 | self.web = data.get('web', None) 158 | self.bittorrent = data.get('bittorrent', None) 159 | 160 | 161 | class Price(BaseModel): 162 | def __init__(self, client, data): 163 | super(Price, self).__init__(client, data) 164 | self.value = data[0] 165 | self.currency = data[1] 166 | 167 | def __cmp__(self, other): 168 | if other.currency == self.currency: 169 | if self.value < other.value: 170 | return -1 171 | elif self.value > other.value: 172 | return 1 173 | else: 174 | return 0 175 | else: 176 | raise NotImplemented("Mixed currencies cannot be compared") 177 | 178 | def __repr__(self): 179 | return "Price: <{value:.2f}{currency}>".format(value=self.value, currency=self.currency) -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.rst -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | __author__ = "Joel Pedraza" 4 | __copyright__ = "Copyright 2014, Joel Pedraza" 5 | __license__ = "MIT" 6 | 7 | try: 8 | from setuptools import setup 9 | except ImportError: 10 | from distutils.core import setup 11 | 12 | setup(name='humblebundle', 13 | version='0.1.1', 14 | description='Humble Indie Bundle API client', 15 | author='Joel Pedraza', 16 | author_email='joel@joelpedraza.com', 17 | url='https://github.com/saik0/humblebundle-python', 18 | download_url='https://github.com/saik0/humblebundle-python/tarball/0.1.1', 19 | install_requires=['requests >= 2.0.0'], 20 | packages=['humblebundle'], 21 | ) 22 | --------------------------------------------------------------------------------