diff --git a/.gitignore b/.gitignore index b629af3..8dff601 100644 --- a/.gitignore +++ b/.gitignore @@ -6,9 +6,11 @@ docs/_build *.egg-info *.egg +.eggs dist build env +htmlcov # Editors .idea diff --git a/.travis.yml b/.travis.yml index 1ad4a10..ca39904 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,13 +1,12 @@ language: python -python: 3.4 -env: - - TOX_ENV=pypy - - TOX_ENV=py33 - - TOX_ENV=py32 - - TOX_ENV=py27 - - TOX_ENV=py26 - - TOX_ENV=docs +python: + - pypy + - pypy3.5 + - 2.7 + - 3.4 + - 3.5 + - 3.6 install: - - pip install coveralls tox -script: tox -e $TOX_ENV + - pip install coveralls tox-travis +script: tox after_success: coveralls diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9724eb7..c3184fd 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,34 @@ +0.3.1 (2019-05-24) +================== +* Fix auth with newer versions of OAuth libraries while retaining backward compatibility + +0.3.0 (2017-01-24) +================== +* Surface errors better +* Use requests-oauthlib auto refresh to automatically refresh tokens if possible + +0.2.4 (2016-11-10) +================== +* Call a hook if it exists when tokens are refreshed + +0.2.3 (2016-07-06) +================== +* Refresh token when it expires + +0.2.2 (2016-03-30) +================== +* Refresh token bugfixes + +0.2.1 (2016-03-28) +================== +* Update requirements to use requests-oauthlib>=0.6.1 + +0.2 (2016-03-23) +================ + +* Drop OAuth1 support. See `OAuth1 deprecated `_ +* Drop py26 and py32 support + 0.1.3 (2015-02-04) ================== diff --git a/LICENSE b/LICENSE index eb83cdf..c9269bf 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright 2012-2015 ORCAS +Copyright 2012-2017 ORCAS Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.rst b/README.rst index ff23090..e1a576d 100644 --- a/README.rst +++ b/README.rst @@ -1,6 +1,8 @@ python-fitbit ============= +.. image:: https://badge.fury.io/py/fitbit.svg + :target: https://badge.fury.io/py/fitbit .. image:: https://travis-ci.org/orcasgit/python-fitbit.svg?branch=master :target: https://travis-ci.org/orcasgit/python-fitbit :alt: Build Status @@ -10,6 +12,9 @@ python-fitbit .. image:: https://requires.io/github/orcasgit/python-fitbit/requirements.png?branch=master :target: https://requires.io/github/orcasgit/python-fitbit/requirements/?branch=master :alt: Requirements Status +.. image:: https://badges.gitter.im/orcasgit/python-fitbit.png + :target: https://gitter.im/orcasgit/python-fitbit + :alt: Gitter chat Fitbit API Python Client Implementation @@ -18,7 +23,7 @@ For documentation: `http://python-fitbit.readthedocs.org/ ', '') # certain methods do not require user keys unauth_client.food_units() - # You'll have to gather the user keys on your own, or try - # ./gather_keys_cli.py for development - authd_client = fitbit.Fitbit('', '', resource_owner_key='', resource_owner_secret='') +Here is an example of authorizing with OAuth 2.0:: + + # You'll have to gather the tokens on your own, or use + # ./gather_keys_oauth2.py + authd_client = fitbit.Fitbit('', '', + access_token='', refresh_token='') authd_client.sleep() Fitbit API @@ -37,9 +40,90 @@ either ``None`` or a ``date`` or ``datetime`` object as ``%Y-%m-%d``. .. autoclass:: fitbit.Fitbit - :private-members: :members: + .. method:: body(date=None, user_id=None, data=None) + + Get body data: https://dev.fitbit.com/docs/body/ + + .. method:: activities(date=None, user_id=None, data=None) + + Get body data: https://dev.fitbit.com/docs/activity/ + + .. method:: foods_log(date=None, user_id=None, data=None) + + Get food logs data: https://dev.fitbit.com/docs/food-logging/#get-food-logs + + .. method:: foods_log_water(date=None, user_id=None, data=None) + + Get water logs data: https://dev.fitbit.com/docs/food-logging/#get-water-logs + + .. method:: sleep(date=None, user_id=None, data=None) + + Get sleep data: https://dev.fitbit.com/docs/sleep/ + + .. method:: heart(date=None, user_id=None, data=None) + + Get heart rate data: https://dev.fitbit.com/docs/heart-rate/ + + .. method:: bp(date=None, user_id=None, data=None) + + Get blood pressure data: https://dev.fitbit.com/docs/heart-rate/ + + .. method:: delete_body(log_id) + + Delete a body log, given a log id + + .. method:: delete_activities(log_id) + + Delete an activity log, given a log id + + .. method:: delete_foods_log(log_id) + + Delete a food log, given a log id + + .. method:: delete_foods_log_water(log_id) + + Delete a water log, given a log id + + .. method:: delete_sleep(log_id) + + Delete a sleep log, given a log id + + .. method:: delete_heart(log_id) + + Delete a heart log, given a log id + + .. method:: delete_bp(log_id) + + Delete a blood pressure log, given a log id + + .. method:: recent_foods(user_id=None, qualifier='') + + Get recently logged foods: https://dev.fitbit.com/docs/food-logging/#get-recent-foods + + .. method:: frequent_foods(user_id=None, qualifier='') + + Get frequently logged foods: https://dev.fitbit.com/docs/food-logging/#get-frequent-foods + + .. method:: favorite_foods(user_id=None, qualifier='') + + Get favorited foods: https://dev.fitbit.com/docs/food-logging/#get-favorite-foods + + .. method:: recent_activities(user_id=None, qualifier='') + + Get recently logged activities: https://dev.fitbit.com/docs/activity/#get-recent-activity-types + + .. method:: frequent_activities(user_id=None, qualifier='') + + Get frequently logged activities: https://dev.fitbit.com/docs/activity/#get-frequent-activities + + .. method:: favorite_activities(user_id=None, qualifier='') + + Get favorited foods: https://dev.fitbit.com/docs/activity/#get-favorite-activities + + + Indices and tables ================== diff --git a/fitbit/__init__.py b/fitbit/__init__.py index 1bf7f1b..0368d08 100644 --- a/fitbit/__init__.py +++ b/fitbit/__init__.py @@ -3,22 +3,22 @@ Fitbit API Library ------------------ -:copyright: 2012-2015 ORCAS. +:copyright: 2012-2019 ORCAS. :license: BSD, see LICENSE for more details. """ -from .api import Fitbit, FitbitOauthClient, FitbitOauth2Client +from .api import Fitbit, FitbitOauth2Client # Meta. __title__ = 'fitbit' __author__ = 'Issac Kelly and ORCAS' __author_email__ = 'bpitcher@orcasinc.com' -__copyright__ = 'Copyright 2012-2015 ORCAS' +__copyright__ = 'Copyright 2012-2017 ORCAS' __license__ = 'Apache 2.0' -__version__ = '0.1.3' -__release__ = '0.1.3' +__version__ = '0.3.1' +__release__ = '0.3.1' # Module namespace. diff --git a/fitbit/api.py b/fitbit/api.py index 72d253c..1b458b1 100644 --- a/fitbit/api.py +++ b/fitbit/api.py @@ -1,8 +1,7 @@ # -*- coding: utf-8 -*- -import requests -import json import datetime -import base64 +import json +import requests try: from urllib.parse import urlencode @@ -10,138 +9,12 @@ # Python 2.x from urllib import urlencode -from requests_oauthlib import OAuth1, OAuth1Session, OAuth2, OAuth2Session -from oauthlib.oauth2 import TokenExpiredError -from oauthlib.common import urldecode -from fitbit.exceptions import (BadResponse, DeleteError, HTTPBadRequest, - HTTPUnauthorized, HTTPForbidden, - HTTPServerError, HTTPConflict, HTTPNotFound, - HTTPTooManyRequests) -from fitbit.utils import curry - - -class FitbitOauthClient(object): - API_ENDPOINT = "https://api.fitbit.com" - AUTHORIZE_ENDPOINT = "https://www.fitbit.com" - API_VERSION = 1 - - request_token_url = "%s/oauth/request_token" % API_ENDPOINT - access_token_url = "%s/oauth/access_token" % API_ENDPOINT - authorization_url = "%s/oauth/authorize" % AUTHORIZE_ENDPOINT - - def __init__(self, client_key, client_secret, resource_owner_key=None, - resource_owner_secret=None, user_id=None, callback_uri=None, - *args, **kwargs): - """ - Create a FitbitOauthClient object. Specify the first 5 parameters if - you have them to access user data. Specify just the first 2 parameters - to access anonymous data and start the set up for user authorization. - - Set callback_uri to a URL and when the user has granted us access at - the fitbit site, fitbit will redirect them to the URL you passed. This - is how we get back the magic verifier string from fitbit if we're a web - app. If we don't pass it, then fitbit will just display the verifier - string for the user to copy and we'll have to ask them to paste it for - us and read it that way. - """ - - self.session = requests.Session() - self.client_key = client_key - self.client_secret = client_secret - self.resource_owner_key = resource_owner_key - self.resource_owner_secret = resource_owner_secret - if user_id: - self.user_id = user_id - params = {'client_secret': client_secret} - if callback_uri: - params['callback_uri'] = callback_uri - if self.resource_owner_key and self.resource_owner_secret: - params['resource_owner_key'] = self.resource_owner_key - params['resource_owner_secret'] = self.resource_owner_secret - self.oauth = OAuth1Session(client_key, **params) - - def _request(self, method, url, **kwargs): - """ - A simple wrapper around requests. - """ - return self.session.request(method, url, **kwargs) - - def make_request(self, url, data={}, method=None, **kwargs): - """ - Builds and makes the OAuth Request, catches errors - - https://wiki.fitbit.com/display/API/API+Response+Format+And+Errors - """ - if not method: - method = 'POST' if data else 'GET' - auth = OAuth1( - self.client_key, self.client_secret, self.resource_owner_key, - self.resource_owner_secret, signature_type='auth_header') - response = self._request(method, url, data=data, auth=auth, **kwargs) - - if response.status_code == 401: - raise HTTPUnauthorized(response) - elif response.status_code == 403: - raise HTTPForbidden(response) - elif response.status_code == 404: - raise HTTPNotFound(response) - elif response.status_code == 409: - raise HTTPConflict(response) - elif response.status_code == 429: - exc = HTTPTooManyRequests(response) - exc.retry_after_secs = int(response.headers['Retry-After']) - raise exc - - elif response.status_code >= 500: - raise HTTPServerError(response) - elif response.status_code >= 400: - raise HTTPBadRequest(response) - return response - - def fetch_request_token(self): - """ - Step 1 of getting authorized to access a user's data at fitbit: this - makes a signed request to fitbit to get a token to use in step 3. - Returns that token.} - """ - - token = self.oauth.fetch_request_token(self.request_token_url) - self.resource_owner_key = token.get('oauth_token') - self.resource_owner_secret = token.get('oauth_token_secret') - return token - - def authorize_token_url(self, **kwargs): - """Step 2: Return the URL the user needs to go to in order to grant us - authorization to look at their data. Then redirect the user to that - URL, open their browser to it, or tell them to copy the URL into their - browser. Allow the client to request the mobile display by passing - the display='touch' argument. - """ - - return self.oauth.authorization_url(self.authorization_url, **kwargs) +from requests.auth import HTTPBasicAuth +from requests_oauthlib import OAuth2Session - def fetch_access_token(self, verifier, token=None): - """Step 3: Given the verifier from fitbit, and optionally a token from - step 1 (not necessary if using the same FitbitOAuthClient object) calls - fitbit again and returns an access token object. Extract the needed - information from that and save it to use in future API calls. - """ - if token: - self.resource_owner_key = token.get('oauth_token') - self.resource_owner_secret = token.get('oauth_token_secret') - - self.oauth = OAuth1Session( - self.client_key, - client_secret=self.client_secret, - resource_owner_key=self.resource_owner_key, - resource_owner_secret=self.resource_owner_secret, - verifier=verifier) - response = self.oauth.fetch_access_token(self.access_token_url) - - self.user_id = response.get('encoded_user_id') - self.resource_owner_key = response.get('oauth_token') - self.resource_owner_secret = response.get('oauth_token_secret') - return response +from . import exceptions +from .compliance import fitbit_compliance_fix +from .utils import curry class FitbitOauth2Client(object): @@ -154,9 +27,9 @@ class FitbitOauth2Client(object): access_token_url = request_token_url refresh_token_url = request_token_url - def __init__(self, client_id , client_secret, - access_token=None, refresh_token=None, - *args, **kwargs): + def __init__(self, client_id, client_secret, access_token=None, + refresh_token=None, expires_at=None, refresh_cb=None, + redirect_uri=None, *args, **kwargs): """ Create a FitbitOauth2Client object. Specify the first 7 parameters if you have them to access user data. Specify just the first 2 parameters @@ -166,139 +39,151 @@ def __init__(self, client_id , client_secret, - access_token, refresh_token are obtained after the user grants permission """ - self.session = requests.Session() - self.client_id = client_id - self.client_secret = client_secret - dec_str = client_id + ':' + client_secret - enc_str = base64.b64encode(dec_str.encode('utf-8')) - self.auth_header = {'Authorization': b'Basic ' + enc_str} - - self.token = {'access_token' : access_token, - 'refresh_token': refresh_token} - - self.oauth = OAuth2Session(client_id) + self.client_id, self.client_secret = client_id, client_secret + token = {} + if access_token and refresh_token: + token.update({ + 'access_token': access_token, + 'refresh_token': refresh_token + }) + if expires_at: + token['expires_at'] = expires_at + self.session = fitbit_compliance_fix(OAuth2Session( + client_id, + auto_refresh_url=self.refresh_token_url, + token_updater=refresh_cb, + token=token, + redirect_uri=redirect_uri, + )) + self.timeout = kwargs.get("timeout", None) def _request(self, method, url, **kwargs): """ A simple wrapper around requests. """ - return self.session.request(method, url, **kwargs) + if self.timeout is not None and 'timeout' not in kwargs: + kwargs['timeout'] = self.timeout + + try: + response = self.session.request(method, url, **kwargs) - def make_request(self, url, data={}, method=None, **kwargs): + # If our current token has no expires_at, or something manages to slip + # through that check + if response.status_code == 401: + d = json.loads(response.content.decode('utf8')) + if d['errors'][0]['errorType'] == 'expired_token': + self.refresh_token() + response = self.session.request(method, url, **kwargs) + + return response + except requests.Timeout as e: + raise exceptions.Timeout(*e.args) + + def make_request(self, url, data=None, method=None, **kwargs): """ Builds and makes the OAuth2 Request, catches errors - https://wiki.fitbit.com/display/API/API+Response+Format+And+Errors + https://dev.fitbit.com/docs/oauth2/#authorization-errors """ - if not method: - method = 'POST' if data else 'GET' - - try: - auth = OAuth2(client_id=self.client_id, token=self.token) - response = self._request(method, url, data=data, auth=auth, **kwargs) - except TokenExpiredError as e: - self.refresh_token() - auth = OAuth2(client_id=self.client_id, token=self.token) - response = self._request(method, url, data=data, auth=auth, **kwargs) - - #yet another token expiration check - #(the above try/except only applies if the expired token was obtained - #using the current instance of the class this is a a general case) - if response.status_code == 401: - d = json.loads(response.content.decode('utf8')) - try: - if(d['errors'][0]['errorType']=='oauth' and - d['errors'][0]['fieldName']=='access_token' and - d['errors'][0]['message'].find('Access token invalid or expired:')==0): - self.refresh_token() - auth = OAuth2(client_id=self.client_id, token=self.token) - response = self._request(method, url, data=data, auth=auth, **kwargs) - except: - pass - - if response.status_code == 401: - raise HTTPUnauthorized(response) - elif response.status_code == 403: - raise HTTPForbidden(response) - elif response.status_code == 404: - raise HTTPNotFound(response) - elif response.status_code == 409: - raise HTTPConflict(response) - elif response.status_code == 429: - exc = HTTPTooManyRequests(response) - exc.retry_after_secs = int(response.headers['Retry-After']) - raise exc - - elif response.status_code >= 500: - raise HTTPServerError(response) - elif response.status_code >= 400: - raise HTTPBadRequest(response) + data = data or {} + method = method or ('POST' if data else 'GET') + response = self._request( + method, + url, + data=data, + client_id=self.client_id, + client_secret=self.client_secret, + **kwargs + ) + + exceptions.detect_and_raise_error(response) + return response def authorize_token_url(self, scope=None, redirect_uri=None, **kwargs): """Step 1: Return the URL the user needs to go to in order to grant us authorization to look at their data. Then redirect the user to that URL, open their browser to it, or tell them to copy the URL into their - browser. + browser. - scope: pemissions that that are being requested [default ask all] - - redirect_uri: url to which the reponse will posted - required only if your app does not have one - for more info see https://wiki.fitbit.com/display/API/OAuth+2.0 - """ - - #the scope parameter is caussing some issues when refreshing tokens - #so not saving it - old_scope = self.oauth.scope; - old_redirect = self.oauth.redirect_uri; - if scope: - self.oauth.scope = scope - else: - self.oauth.scope =["activity", "nutrition","heartrate","location", "nutrition","profile","settings","sleep","social","weight"] + - redirect_uri: url to which the response will posted. required here + unless you specify only one Callback URL on the fitbit app or + you already passed it to the constructor + for more info see https://dev.fitbit.com/docs/oauth2/ + """ + + self.session.scope = scope or [ + "activity", + "nutrition", + "heartrate", + "location", + "nutrition", + "profile", + "settings", + "sleep", + "social", + "weight", + ] if redirect_uri: - self.oauth.redirect_uri = redirect_uri - + self.session.redirect_uri = redirect_uri - out = self.oauth.authorization_url(self.authorization_url, **kwargs) - self.oauth.scope = old_scope - self.oauth.redirect_uri = old_redirect - return(out) + return self.session.authorization_url(self.authorization_url, **kwargs) - def fetch_access_token(self, code, redirect_uri): + def fetch_access_token(self, code, redirect_uri=None): - """Step 2: Given the code from fitbit from step 1, call + """Step 2: Given the code from fitbit from step 1, call fitbit again and returns an access token object. Extract the needed information from that and save it to use in future API calls. the token is internally saved """ - auth = OAuth2Session(self.client_id, redirect_uri=redirect_uri) - self.token = auth.fetch_token(self.access_token_url, headers=self.auth_header, code=code) - - return self.token + if redirect_uri: + self.session.redirect_uri = redirect_uri + return self.session.fetch_token( + self.access_token_url, + username=self.client_id, + password=self.client_secret, + client_secret=self.client_secret, + code=code) def refresh_token(self): - """Step 3: obtains a new access_token from the the refresh token - obtained in step 2. - the token is internally saved - """ - ##the method in oauth does not allow a custom header (issue created #182) - ## in the mean time here is a request from the ground up - #out = self.oauth.refresh_token(self.refresh_token_url, - #refresh_token=self.token['refresh_token'], - #kwarg=self.auth_header) - - auth = OAuth2Session(self.client_id) - body = auth._client.prepare_refresh_body(refresh_token=self.token['refresh_token']) - r = auth.post(self.refresh_token_url, data=dict(urldecode(body)), verify=True,headers=self.auth_header) - auth._client.parse_request_body_response(r.text, scope=self.oauth.scope) - self.oauth.token = auth._client.token - self.token = auth._client.token - return(self.token) - + """Step 3: obtains a new access_token from the the refresh token + obtained in step 2. Only do the refresh if there is `token_updater(),` + which saves the token. + """ + token = {} + if self.session.token_updater: + token = self.session.refresh_token( + self.refresh_token_url, + auth=HTTPBasicAuth(self.client_id, self.client_secret) + ) + self.session.token_updater(token) + return token class Fitbit(object): + """ + Before using this class, create a Fitbit app + `here `_. There you will get the client id + and secret needed to instantiate this class. When first authorizing a user, + make sure to pass the `redirect_uri` keyword arg so fitbit will know where + to return to when the authorization is complete. See + `gather_keys_oauth2.py `_ + for a reference implementation of the authorization process. You should + save ``access_token``, ``refresh_token``, and ``expires_at`` from the + returned token for each user you authorize. + + When instantiating this class for use with an already authorized user, pass + in the ``access_token``, ``refresh_token``, and ``expires_at`` keyword + arguments. We also strongly recommend passing in a ``refresh_cb`` keyword + argument, which should be a function taking one argument: a token dict. + When that argument is present, we will automatically refresh the access + token when needed and call this function so that you can save the updated + token data. If you don't save the updated information, then you could end + up with invalid access and refresh tokens, and the only way to recover from + that is to reauthorize the user. + """ US = 'en_US' METRIC = 'en_UK' @@ -324,17 +209,23 @@ class Fitbit(object): 'frequent', ] - def __init__(self, client_key, client_secret, oauth2=False, system=US, **kwargs): + def __init__(self, client_id, client_secret, access_token=None, + refresh_token=None, expires_at=None, refresh_cb=None, + redirect_uri=None, system=US, **kwargs): """ - oauth1: Fitbit(, , resource_owner_key=, resource_owner_secret=) - oauth2: Fitbit(, , oauth2=True, access_token=, refresh_token=) + Fitbit(, , access_token=, refresh_token=) """ self.system = system - - if oauth2: - self.client = FitbitOauth2Client(client_key, client_secret, **kwargs) - else: - self.client = FitbitOauthClient(client_key, client_secret, **kwargs) + self.client = FitbitOauth2Client( + client_id, + client_secret, + access_token=access_token, + refresh_token=refresh_token, + expires_at=expires_at, + refresh_cb=refresh_cb, + redirect_uri=redirect_uri, + **kwargs + ) # All of these use the same patterns, define the method for accessing # creating and deleting records once, and use curry to make individual @@ -355,7 +246,7 @@ def __init__(self, client_key, client_secret, oauth2=False, system=US, **kwargs) qualifier=qualifier)) def make_request(self, *args, **kwargs): - ##@ This should handle data level errors, improper requests, and bad + # This should handle data level errors, improper requests, and bad # serialization headers = kwargs.get('headers', {}) headers.update({'Accept-Language': self.system}) @@ -370,11 +261,11 @@ def make_request(self, *args, **kwargs): if response.status_code == 204: return True else: - raise DeleteError(response) + raise exceptions.DeleteError(response) try: rep = json.loads(response.content.decode('utf8')) except ValueError: - raise BadResponse + raise exceptions.BadResponse return rep @@ -388,7 +279,7 @@ def user_profile_get(self, user_id=None): This is not the same format that the GET comes back in, GET requests are wrapped in {'user': } - https://wiki.fitbit.com/display/API/API-Get-User-Info + https://dev.fitbit.com/docs/user/ """ url = "{0}/{1}/user/{2}/profile.json".format(*self._get_common_args(user_id)) return self.make_request(url) @@ -402,7 +293,7 @@ def user_profile_update(self, data): This is not the same format that the GET comes back in, GET requests are wrapped in {'user': } - https://wiki.fitbit.com/display/API/API-Update-User-Info + https://dev.fitbit.com/docs/user/#update-profile """ url = "{0}/{1}/user/-/profile.json".format(*self._get_common_args()) return self.make_request(url, data) @@ -440,7 +331,7 @@ def _COLLECTION_RESOURCE(self, resource, date=None, user_id=None, heart(date=None, user_id=None, data=None) bp(date=None, user_id=None, data=None) - * https://wiki.fitbit.com/display/API/Fitbit+Resource+Access+API + * https://dev.fitbit.com/docs/ """ if not date: @@ -483,17 +374,159 @@ def _DELETE_COLLECTION_RESOURCE(self, resource, log_id): response = self.make_request(url, method='DELETE') return response + def _resource_goal(self, resource, data={}, period=None): + """ Handles GETting and POSTing resource goals of all types """ + url = "{0}/{1}/user/-/{resource}/goal{postfix}.json".format( + *self._get_common_args(), + resource=resource, + postfix=('s/' + period) if period else '' + ) + return self.make_request(url, data=data) + + def _filter_nones(self, data): + filter_nones = lambda item: item[1] is not None + filtered_kwargs = list(filter(filter_nones, data.items())) + return {} if not filtered_kwargs else dict(filtered_kwargs) + + def body_fat_goal(self, fat=None): + """ + Implements the following APIs + + * https://dev.fitbit.com/docs/body/#get-body-goals + * https://dev.fitbit.com/docs/body/#update-body-fat-goal + + Pass no arguments to get the body fat goal. Pass a ``fat`` argument + to update the body fat goal. + + Arguments: + * ``fat`` -- Target body fat in %; in the format X.XX + """ + return self._resource_goal('body/log/fat', {'fat': fat} if fat else {}) + + def body_weight_goal(self, start_date=None, start_weight=None, weight=None): + """ + Implements the following APIs + + * https://dev.fitbit.com/docs/body/#get-body-goals + * https://dev.fitbit.com/docs/body/#update-weight-goal + + Pass no arguments to get the body weight goal. Pass ``start_date``, + ``start_weight`` and optionally ``weight`` to set the weight goal. + ``weight`` is required if it hasn't been set yet. + + Arguments: + * ``start_date`` -- Weight goal start date; in the format yyyy-MM-dd + * ``start_weight`` -- Weight goal start weight; in the format X.XX + * ``weight`` -- Weight goal target weight; in the format X.XX + """ + data = self._filter_nones({ + 'startDate': start_date, + 'startWeight': start_weight, + 'weight': weight + }) + if data and not ('startDate' in data and 'startWeight' in data): + raise ValueError('start_date and start_weight are both required') + return self._resource_goal('body/log/weight', data) + + def activities_daily_goal(self, calories_out=None, active_minutes=None, + floors=None, distance=None, steps=None): + """ + Implements the following APIs for period equal to daily + + https://dev.fitbit.com/docs/activity/#get-activity-goals + https://dev.fitbit.com/docs/activity/#update-activity-goals + + Pass no arguments to get the daily activities goal. Pass any one of + the optional arguments to set that component of the daily activities + goal. + + Arguments: + * ``calories_out`` -- New goal value; in an integer format + * ``active_minutes`` -- New goal value; in an integer format + * ``floors`` -- New goal value; in an integer format + * ``distance`` -- New goal value; in the format X.XX or integer + * ``steps`` -- New goal value; in an integer format + """ + data = self._filter_nones({ + 'caloriesOut': calories_out, + 'activeMinutes': active_minutes, + 'floors': floors, + 'distance': distance, + 'steps': steps + }) + return self._resource_goal('activities', data, period='daily') + + def activities_weekly_goal(self, distance=None, floors=None, steps=None): + """ + Implements the following APIs for period equal to weekly + + https://dev.fitbit.com/docs/activity/#get-activity-goals + https://dev.fitbit.com/docs/activity/#update-activity-goals + + Pass no arguments to get the weekly activities goal. Pass any one of + the optional arguments to set that component of the weekly activities + goal. + + Arguments: + * ``distance`` -- New goal value; in the format X.XX or integer + * ``floors`` -- New goal value; in an integer format + * ``steps`` -- New goal value; in an integer format + """ + data = self._filter_nones({'distance': distance, 'floors': floors, + 'steps': steps}) + return self._resource_goal('activities', data, period='weekly') + + def food_goal(self, calories=None, intensity=None, personalized=None): + """ + Implements the following APIs + + https://dev.fitbit.com/docs/food-logging/#get-food-goals + https://dev.fitbit.com/docs/food-logging/#update-food-goal + + Pass no arguments to get the food goal. Pass at least ``calories`` or + ``intensity`` and optionally ``personalized`` to update the food goal. + + Arguments: + * ``calories`` -- Manual Calorie Consumption Goal; calories, integer; + * ``intensity`` -- Food Plan intensity; (MAINTENANCE, EASIER, MEDIUM, KINDAHARD, HARDER); + * ``personalized`` -- Food Plan type; ``True`` or ``False`` + """ + data = self._filter_nones({'calories': calories, 'intensity': intensity, + 'personalized': personalized}) + if data and not ('calories' in data or 'intensity' in data): + raise ValueError('Either calories or intensity is required') + return self._resource_goal('foods/log', data) + + def water_goal(self, target=None): + """ + Implements the following APIs + + https://dev.fitbit.com/docs/food-logging/#get-water-goal + https://dev.fitbit.com/docs/food-logging/#update-water-goal + + Pass no arguments to get the water goal. Pass ``target`` to update it. + + Arguments: + * ``target`` -- Target water goal in the format X.X, will be set in unit based on locale + """ + data = self._filter_nones({'target': target}) + return self._resource_goal('foods/log/water', data) + def time_series(self, resource, user_id=None, base_date='today', period=None, end_date=None): """ - The time series is a LOT of methods, (documented at url below) so they + The time series is a LOT of methods, (documented at urls below) so they don't get their own method. They all follow the same patterns, and return similar formats. Taking liberty, this assumes a base_date of today, the current user, and a 1d period. - https://wiki.fitbit.com/display/API/API-Get-Time-Series + https://dev.fitbit.com/docs/activity/#activity-time-series + https://dev.fitbit.com/docs/body/#body-time-series + https://dev.fitbit.com/docs/food-logging/#food-or-water-time-series + https://dev.fitbit.com/docs/heart-rate/#heart-rate-time-series + https://dev.fitbit.com/docs/sleep/#sleep-time-series """ if period and end_date: raise TypeError("Either end_date or period can be specified, not both") @@ -518,10 +551,10 @@ def intraday_time_series(self, resource, base_date='today', detail_level='1min', """ The intraday time series extends the functionality of the regular time series, but returning data at a more granular level for a single day, defaulting to 1 minute intervals. To access this feature, one must - send an email to api@fitbit.com and request to have access to the Partner API - (see https://wiki.fitbit.com/display/API/Fitbit+Partner+API). For details on the resources available, see: + fill out the Private Support form here (see https://dev.fitbit.com/docs/help/). + For details on the resources available and more information on how to get access, see: - https://wiki.fitbit.com/display/API/API-Get-Intraday-Time-Series + https://dev.fitbit.com/docs/activity/#get-activity-intraday-time-series """ # Check that the time range is valid @@ -530,8 +563,14 @@ def intraday_time_series(self, resource, base_date='today', detail_level='1min', if not all(time_map) and any(time_map): raise TypeError('You must provide both the end and start time or neither') - if not detail_level in ['1min', '15min']: - raise ValueError("Period must be either '1min' or '15min'") + """ + Per + https://dev.fitbit.com/docs/activity/#get-activity-intraday-time-series + the detail-level is now (OAuth 2.0 ): + either "1min" or "15min" (optional). "1sec" for heart rate. + """ + if not detail_level in ['1sec', '1min', '15min']: + raise ValueError("Period must be either '1sec', '1min', or '15min'") url = "{0}/{1}/user/-/{resource}/date/{base_date}/1d/{detail_level}".format( *self._get_common_args(), @@ -554,10 +593,10 @@ def intraday_time_series(self, resource, base_date='today', detail_level='1min', def activity_stats(self, user_id=None, qualifier=''): """ - * https://wiki.fitbit.com/display/API/API-Get-Activity-Stats - * https://wiki.fitbit.com/display/API/API-Get-Favorite-Activities - * https://wiki.fitbit.com/display/API/API-Get-Recent-Activities - * https://wiki.fitbit.com/display/API/API-Get-Frequent-Activities + * https://dev.fitbit.com/docs/activity/#activity-types + * https://dev.fitbit.com/docs/activity/#get-favorite-activities + * https://dev.fitbit.com/docs/activity/#get-recent-activity-types + * https://dev.fitbit.com/docs/activity/#get-frequent-activities This implements the following methods:: @@ -588,9 +627,9 @@ def _food_stats(self, user_id=None, qualifier=''): favorite_foods(user_id=None, qualifier='') frequent_foods(user_id=None, qualifier='') - * https://wiki.fitbit.com/display/API/API-Get-Recent-Foods - * https://wiki.fitbit.com/display/API/API-Get-Frequent-Foods - * https://wiki.fitbit.com/display/API/API-Get-Favorite-Foods + * https://dev.fitbit.com/docs/food-logging/#get-favorite-foods + * https://dev.fitbit.com/docs/food-logging/#get-frequent-foods + * https://dev.fitbit.com/docs/food-logging/#get-recent-foods """ url = "{0}/{1}/user/{2}/foods/log/{qualifier}.json".format( *self._get_common_args(user_id), @@ -600,7 +639,7 @@ def _food_stats(self, user_id=None, qualifier=''): def add_favorite_activity(self, activity_id): """ - https://wiki.fitbit.com/display/API/API-Add-Favorite-Activity + https://dev.fitbit.com/docs/activity/#add-favorite-activity """ url = "{0}/{1}/user/-/activities/favorite/{activity_id}.json".format( *self._get_common_args(), @@ -610,14 +649,14 @@ def add_favorite_activity(self, activity_id): def log_activity(self, data): """ - https://wiki.fitbit.com/display/API/API-Log-Activity + https://dev.fitbit.com/docs/activity/#log-activity """ url = "{0}/{1}/user/-/activities.json".format(*self._get_common_args()) - return self.make_request(url, data = data) + return self.make_request(url, data=data) def delete_favorite_activity(self, activity_id): """ - https://wiki.fitbit.com/display/API/API-Delete-Favorite-Activity + https://dev.fitbit.com/docs/activity/#delete-favorite-activity """ url = "{0}/{1}/user/-/activities/favorite/{activity_id}.json".format( *self._get_common_args(), @@ -627,7 +666,7 @@ def delete_favorite_activity(self, activity_id): def add_favorite_food(self, food_id): """ - https://wiki.fitbit.com/display/API/API-Add-Favorite-Food + https://dev.fitbit.com/docs/food-logging/#add-favorite-food """ url = "{0}/{1}/user/-/foods/log/favorite/{food_id}.json".format( *self._get_common_args(), @@ -637,7 +676,7 @@ def add_favorite_food(self, food_id): def delete_favorite_food(self, food_id): """ - https://wiki.fitbit.com/display/API/API-Delete-Favorite-Food + https://dev.fitbit.com/docs/food-logging/#delete-favorite-food """ url = "{0}/{1}/user/-/foods/log/favorite/{food_id}.json".format( *self._get_common_args(), @@ -647,28 +686,28 @@ def delete_favorite_food(self, food_id): def create_food(self, data): """ - https://wiki.fitbit.com/display/API/API-Create-Food + https://dev.fitbit.com/docs/food-logging/#create-food """ url = "{0}/{1}/user/-/foods.json".format(*self._get_common_args()) return self.make_request(url, data=data) def get_meals(self): """ - https://wiki.fitbit.com/display/API/API-Get-Meals + https://dev.fitbit.com/docs/food-logging/#get-meals """ url = "{0}/{1}/user/-/meals.json".format(*self._get_common_args()) return self.make_request(url) def get_devices(self): """ - https://wiki.fitbit.com/display/API/API-Get-Devices + https://dev.fitbit.com/docs/devices/#get-devices """ url = "{0}/{1}/user/-/devices.json".format(*self._get_common_args()) return self.make_request(url) def get_alarms(self, device_id): """ - https://wiki.fitbit.com/display/API/API-Devices-Get-Alarms + https://dev.fitbit.com/docs/devices/#get-alarms """ url = "{0}/{1}/user/-/devices/tracker/{device_id}/alarms.json".format( *self._get_common_args(), @@ -676,10 +715,11 @@ def get_alarms(self, device_id): ) return self.make_request(url) - def add_alarm(self, device_id, alarm_time, week_days, recurring=False, enabled=True, label=None, - snooze_length=None, snooze_count=None, vibe='DEFAULT'): + def add_alarm(self, device_id, alarm_time, week_days, recurring=False, + enabled=True, label=None, snooze_length=None, + snooze_count=None, vibe='DEFAULT'): """ - https://wiki.fitbit.com/display/API/API-Devices-Add-Alarm + https://dev.fitbit.com/docs/devices/#add-alarm alarm_time should be a timezone aware datetime object. """ url = "{0}/{1}/user/-/devices/tracker/{device_id}/alarms.json".format( @@ -712,7 +752,7 @@ def add_alarm(self, device_id, alarm_time, week_days, recurring=False, enabled=T def update_alarm(self, device_id, alarm_id, alarm_time, week_days, recurring=False, enabled=True, label=None, snooze_length=None, snooze_count=None, vibe='DEFAULT'): """ - https://wiki.fitbit.com/display/API/API-Devices-Update-Alarm + https://dev.fitbit.com/docs/devices/#update-alarm alarm_time should be a timezone aware datetime object. """ # TODO Refactor with create_alarm. Tons of overlap. @@ -747,7 +787,7 @@ def update_alarm(self, device_id, alarm_id, alarm_time, week_days, recurring=Fal def delete_alarm(self, device_id, alarm_id): """ - https://wiki.fitbit.com/display/API/API-Devices-Delete-Alarm + https://dev.fitbit.com/docs/devices/#delete-alarm """ url = "{0}/{1}/user/-/devices/tracker/{device_id}/alarms/{alarm_id}.json".format( *self._get_common_args(), @@ -758,7 +798,7 @@ def delete_alarm(self, device_id, alarm_id): def get_sleep(self, date): """ - https://wiki.fitbit.com/display/API/API-Get-Sleep + https://dev.fitbit.com/docs/sleep/#get-sleep-logs date should be a datetime.date object. """ url = "{0}/{1}/user/-/sleep/date/{year}-{month}-{day}.json".format( @@ -771,7 +811,7 @@ def get_sleep(self, date): def log_sleep(self, start_time, duration): """ - https://wiki.fitbit.com/display/API/API-Log-Sleep + https://dev.fitbit.com/docs/sleep/#log-sleep start time should be a datetime object. We will be using the year, month, day, hour, and minute. """ data = { @@ -784,14 +824,14 @@ def log_sleep(self, start_time, duration): def activities_list(self): """ - https://wiki.fitbit.com/display/API/API-Browse-Activities + https://dev.fitbit.com/docs/activity/#browse-activity-types """ url = "{0}/{1}/activities.json".format(*self._get_common_args()) return self.make_request(url) def activity_detail(self, activity_id): """ - https://wiki.fitbit.com/display/API/API-Get-Activity + https://dev.fitbit.com/docs/activity/#get-activity-type """ url = "{0}/{1}/activities/{activity_id}.json".format( *self._get_common_args(), @@ -801,7 +841,7 @@ def activity_detail(self, activity_id): def search_foods(self, query): """ - https://wiki.fitbit.com/display/API/API-Search-Foods + https://dev.fitbit.com/docs/food-logging/#search-foods """ url = "{0}/{1}/foods/search.json?{encoded_query}".format( *self._get_common_args(), @@ -811,7 +851,7 @@ def search_foods(self, query): def food_detail(self, food_id): """ - https://wiki.fitbit.com/display/API/API-Get-Food + https://dev.fitbit.com/docs/food-logging/#get-food """ url = "{0}/{1}/foods/{food_id}.json".format( *self._get_common_args(), @@ -821,14 +861,14 @@ def food_detail(self, food_id): def food_units(self): """ - https://wiki.fitbit.com/display/API/API-Get-Food-Units + https://dev.fitbit.com/docs/food-logging/#get-food-units """ url = "{0}/{1}/foods/units.json".format(*self._get_common_args()) return self.make_request(url) def get_bodyweight(self, base_date=None, user_id=None, period=None, end_date=None): """ - https://wiki.fitbit.com/display/API/API-Get-Body-Weight + https://dev.fitbit.com/docs/body/#get-weight-logs base_date should be a datetime.date object (defaults to today), period can be '1d', '7d', '30d', '1w', '1m', '3m', '6m', '1y', 'max' or None end_date should be a datetime.date object, or None. @@ -839,7 +879,7 @@ def get_bodyweight(self, base_date=None, user_id=None, period=None, end_date=Non def get_bodyfat(self, base_date=None, user_id=None, period=None, end_date=None): """ - https://wiki.fitbit.com/display/API/API-Get-Body-fat + https://dev.fitbit.com/docs/body/#get-body-fat-logs base_date should be a datetime.date object (defaults to today), period can be '1d', '7d', '30d', '1w', '1m', '3m', '6m', '1y', 'max' or None end_date should be a datetime.date object, or None. @@ -876,14 +916,14 @@ def _get_body(self, type_, base_date=None, user_id=None, period=None, def get_friends(self, user_id=None): """ - https://wiki.fitbit.com/display/API/API-Get-Friends + https://dev.fitbit.com/docs/friends/#get-friends """ url = "{0}/{1}/user/{2}/friends.json".format(*self._get_common_args(user_id)) return self.make_request(url) def get_friends_leaderboard(self, period): """ - https://wiki.fitbit.com/display/API/API-Get-Friends-Leaderboard + https://dev.fitbit.com/docs/friends/#get-friends-leaderboard """ if not period in ['7d', '30d']: raise ValueError("Period must be one of '7d', '30d'") @@ -895,7 +935,7 @@ def get_friends_leaderboard(self, period): def invite_friend(self, data): """ - https://wiki.fitbit.com/display/API/API-Create-Invite + https://dev.fitbit.com/docs/friends/#invite-friend """ url = "{0}/{1}/user/-/friends/invitations.json".format(*self._get_common_args()) return self.make_request(url, data=data) @@ -903,20 +943,20 @@ def invite_friend(self, data): def invite_friend_by_email(self, email): """ Convenience Method for - https://wiki.fitbit.com/display/API/API-Create-Invite + https://dev.fitbit.com/docs/friends/#invite-friend """ return self.invite_friend({'invitedUserEmail': email}) def invite_friend_by_userid(self, user_id): """ Convenience Method for - https://wiki.fitbit.com/display/API/API-Create-Invite + https://dev.fitbit.com/docs/friends/#invite-friend """ return self.invite_friend({'invitedUserId': user_id}) def respond_to_invite(self, other_user_id, accept=True): """ - https://wiki.fitbit.com/display/API/API-Accept-Invite + https://dev.fitbit.com/docs/friends/#respond-to-friend-invitation """ url = "{0}/{1}/user/-/friends/invitations/{user_id}.json".format( *self._get_common_args(), @@ -939,7 +979,7 @@ def reject_invite(self, other_user_id): def get_badges(self, user_id=None): """ - https://wiki.fitbit.com/display/API/API-Get-Badges + https://dev.fitbit.com/docs/friends/#badges """ url = "{0}/{1}/user/{2}/badges.json".format(*self._get_common_args(user_id)) return self.make_request(url) @@ -947,7 +987,7 @@ def get_badges(self, user_id=None): def subscription(self, subscription_id, subscriber_id, collection=None, method='POST'): """ - https://wiki.fitbit.com/display/API/Fitbit+Subscriptions+API + https://dev.fitbit.com/docs/subscriptions/ """ base_url = "{0}/{1}/user/-{collection}/apiSubscriptions/{end_string}.json" kwargs = {'collection': '', 'end_string': subscription_id} @@ -964,17 +1004,10 @@ def subscription(self, subscription_id, subscriber_id, collection=None, def list_subscriptions(self, collection=''): """ - https://wiki.fitbit.com/display/API/Fitbit+Subscriptions+API + https://dev.fitbit.com/docs/subscriptions/#getting-a-list-of-subscriptions """ url = "{0}/{1}/user/-{collection}/apiSubscriptions.json".format( *self._get_common_args(), collection='/{0}'.format(collection) if collection else '' ) return self.make_request(url) - - @classmethod - def from_oauth_keys(self, client_key, client_secret, user_key=None, - user_secret=None, user_id=None, system=US): - client = FitbitOauthClient(client_key, client_secret, user_key, - user_secret, user_id) - return self(client, system) diff --git a/fitbit/compliance.py b/fitbit/compliance.py new file mode 100644 index 0000000..cec533b --- /dev/null +++ b/fitbit/compliance.py @@ -0,0 +1,26 @@ +""" +The Fitbit API breaks from the OAuth2 RFC standard by returning an "errors" +object list, rather than a single "error" string. This puts hooks in place so +that oauthlib can process an error in the results from access token and refresh +token responses. This is necessary to prevent getting the generic red herring +MissingTokenError. +""" + +from json import loads, dumps + +from oauthlib.common import to_unicode + + +def fitbit_compliance_fix(session): + + def _missing_error(r): + token = loads(r.text) + if 'errors' in token: + # Set the error to the first one we have + token['error'] = token['errors'][0]['errorType'] + r._content = to_unicode(dumps(token)).encode('UTF-8') + return r + + session.register_compliance_hook('access_token_response', _missing_error) + session.register_compliance_hook('refresh_token_response', _missing_error) + return session diff --git a/fitbit/exceptions.py b/fitbit/exceptions.py index d6249ea..677958a 100644 --- a/fitbit/exceptions.py +++ b/fitbit/exceptions.py @@ -15,6 +15,13 @@ class DeleteError(Exception): pass +class Timeout(Exception): + """ + Used when a timeout occurs. + """ + pass + + class HTTPException(Exception): def __init__(self, response, *args, **kwargs): try: @@ -68,3 +75,22 @@ class HTTPServerError(HTTPException): """Generic >= 500 error """ pass + + +def detect_and_raise_error(response): + if response.status_code == 401: + raise HTTPUnauthorized(response) + elif response.status_code == 403: + raise HTTPForbidden(response) + elif response.status_code == 404: + raise HTTPNotFound(response) + elif response.status_code == 409: + raise HTTPConflict(response) + elif response.status_code == 429: + exc = HTTPTooManyRequests(response) + exc.retry_after_secs = int(response.headers['Retry-After']) + raise exc + elif response.status_code >= 500: + raise HTTPServerError(response) + elif response.status_code >= 400: + raise HTTPBadRequest(response) diff --git a/fitbit_tests/__init__.py b/fitbit_tests/__init__.py index 34895ec..d5f28f7 100644 --- a/fitbit_tests/__init__.py +++ b/fitbit_tests/__init__.py @@ -1,6 +1,6 @@ import unittest from .test_exceptions import ExceptionTest -from .test_auth import AuthTest, Auth2Test +from .test_auth import Auth2Test from .test_api import ( APITest, CollectionResourceTest, @@ -12,15 +12,8 @@ def all_tests(consumer_key="", consumer_secret="", user_key=None, user_secret=None): - kwargs = { - "consumer_key": consumer_key, - "consumer_secret": consumer_secret, - "user_key": user_key, - "user_secret": user_secret, - } suite = unittest.TestSuite() suite.addTest(unittest.makeSuite(ExceptionTest)) - suite.addTest(unittest.makeSuite(AuthTest)) suite.addTest(unittest.makeSuite(Auth2Test)) suite.addTest(unittest.makeSuite(APITest)) suite.addTest(unittest.makeSuite(CollectionResourceTest)) diff --git a/fitbit_tests/test_api.py b/fitbit_tests/test_api.py index e94797b..f019d72 100644 --- a/fitbit_tests/test_api.py +++ b/fitbit_tests/test_api.py @@ -1,8 +1,9 @@ from unittest import TestCase import datetime import mock +import requests from fitbit import Fitbit -from fitbit.exceptions import DeleteError +from fitbit.exceptions import DeleteError, Timeout URLBASE = "%s/%s/user" % (Fitbit.API_ENDPOINT, Fitbit.API_VERSION) @@ -16,14 +17,57 @@ def common_api_test(self, funcname, args, kwargs, expected_args, expected_kwargs # arguments and verify that make_request is called with the expected args and kwargs with mock.patch.object(self.fb, 'make_request') as make_request: retval = getattr(self.fb, funcname)(*args, **kwargs) - args, kwargs = make_request.call_args - self.assertEqual(expected_args, args) - self.assertEqual(expected_kwargs, kwargs) + mr_args, mr_kwargs = make_request.call_args + self.assertEqual(expected_args, mr_args) + self.assertEqual(expected_kwargs, mr_kwargs) def verify_raises(self, funcname, args, kwargs, exc): self.assertRaises(exc, getattr(self.fb, funcname), *args, **kwargs) +class TimeoutTest(TestCase): + + def setUp(self): + self.fb = Fitbit('x', 'y') + self.fb_timeout = Fitbit('x', 'y', timeout=10) + + self.test_url = 'invalid://do.not.connect' + + def test_fb_without_timeout(self): + with mock.patch.object(self.fb.client.session, 'request') as request: + mock_response = mock.Mock() + mock_response.status_code = 200 + mock_response.content = b'{}' + request.return_value = mock_response + result = self.fb.make_request(self.test_url) + + request.assert_called_once() + self.assertNotIn('timeout', request.call_args[1]) + self.assertEqual({}, result) + + def test_fb_with_timeout__timing_out(self): + with mock.patch.object(self.fb_timeout.client.session, 'request') as request: + request.side_effect = requests.Timeout('Timed out') + with self.assertRaisesRegexp(Timeout, 'Timed out'): + self.fb_timeout.make_request(self.test_url) + + request.assert_called_once() + self.assertEqual(10, request.call_args[1]['timeout']) + + def test_fb_with_timeout__not_timing_out(self): + with mock.patch.object(self.fb_timeout.client.session, 'request') as request: + mock_response = mock.Mock() + mock_response.status_code = 200 + mock_response.content = b'{}' + request.return_value = mock_response + + result = self.fb_timeout.make_request(self.test_url) + + request.assert_called_once() + self.assertEqual(10, request.call_args[1]['timeout']) + self.assertEqual({}, result) + + class APITest(TestBase): """ Tests for python-fitbit API, not directly involved in getting @@ -34,7 +78,7 @@ def test_make_request(self): # If make_request returns a response with status 200, # we get back the json decoded value that was in the response.content ARGS = (1, 2) - KWARGS = { 'a': 3, 'b': 4, 'headers': {'Accept-Language': self.fb.system}} + KWARGS = {'a': 3, 'b': 4, 'headers': {'Accept-Language': self.fb.system}} mock_response = mock.Mock() mock_response.status_code = 200 mock_response.content = b"1" @@ -54,7 +98,7 @@ def test_make_request_202(self): mock_response.status_code = 202 mock_response.content = "1" ARGS = (1, 2) - KWARGS = { 'a': 3, 'b': 4, 'Accept-Language': self.fb.system} + KWARGS = {'a': 3, 'b': 4, 'Accept-Language': self.fb.system} with mock.patch.object(self.fb.client, 'make_request') as client_make_request: client_make_request.return_value = mock_response retval = self.fb.make_request(*ARGS, **KWARGS) @@ -67,7 +111,7 @@ def test_make_request_delete_204(self): mock_response.status_code = 204 mock_response.content = "1" ARGS = (1, 2) - KWARGS = { 'a': 3, 'b': 4, 'method': 'DELETE', 'Accept-Language': self.fb.system} + KWARGS = {'a': 3, 'b': 4, 'method': 'DELETE', 'Accept-Language': self.fb.system} with mock.patch.object(self.fb.client, 'make_request') as client_make_request: client_make_request.return_value = mock_response retval = self.fb.make_request(*ARGS, **KWARGS) @@ -80,7 +124,7 @@ def test_make_request_delete_not_204(self): mock_response.status_code = 205 mock_response.content = "1" ARGS = (1, 2) - KWARGS = { 'a': 3, 'b': 4, 'method': 'DELETE', 'Accept-Language': self.fb.system} + KWARGS = {'a': 3, 'b': 4, 'method': 'DELETE', 'Accept-Language': self.fb.system} with mock.patch.object(self.fb.client, 'make_request') as client_make_request: client_make_request.return_value = mock_response self.assertRaises(DeleteError, self.fb.make_request, *ARGS, **KWARGS) @@ -93,7 +137,7 @@ def test_all_args(self): resource = "RESOURCE" date = datetime.date(1962, 1, 13) user_id = "bilbo" - data = { 'a': 1, 'b': 2} + data = {'a': 1, 'b': 2} expected_data = data.copy() expected_data['date'] = date.strftime("%Y-%m-%d") url = URLBASE + "/%s/%s.json" % (user_id, resource) @@ -104,17 +148,17 @@ def test_date_string(self): resource = "RESOURCE" date = "1962-1-13" user_id = "bilbo" - data = { 'a': 1, 'b': 2} + data = {'a': 1, 'b': 2} expected_data = data.copy() expected_data['date'] = date url = URLBASE + "/%s/%s.json" % (user_id, resource) - self.common_api_test('_COLLECTION_RESOURCE',(resource, date, user_id, data), {}, (url, expected_data), {} ) + self.common_api_test('_COLLECTION_RESOURCE', (resource, date, user_id, data), {}, (url, expected_data), {}) def test_no_date(self): # If we omit the date, it uses today resource = "RESOURCE" user_id = "bilbo" - data = { 'a': 1, 'b': 2} + data = {'a': 1, 'b': 2} expected_data = data.copy() expected_data['date'] = datetime.date.today().strftime("%Y-%m-%d") # expect today url = URLBASE + "/%s/%s.json" % (user_id, resource) @@ -125,12 +169,17 @@ def test_no_userid(self): resource = "RESOURCE" date = datetime.date(1962, 1, 13) user_id = None - data = { 'a': 1, 'b': 2} + data = {'a': 1, 'b': 2} expected_data = data.copy() expected_data['date'] = date.strftime("%Y-%m-%d") expected_user_id = "-" url = URLBASE + "/%s/%s.json" % (expected_user_id, resource) - self.common_api_test('_COLLECTION_RESOURCE', (resource, date, user_id, data), {}, (url,expected_data), {}) + self.common_api_test( + '_COLLECTION_RESOURCE', + (resource, date, user_id, data), {}, + (url, expected_data), + {} + ) def test_no_data(self): # If we omit the data arg, it does the right thing @@ -139,7 +188,7 @@ def test_no_data(self): user_id = "bilbo" data = None url = URLBASE + "/%s/%s/date/%s.json" % (user_id, resource, date) - self.common_api_test('_COLLECTION_RESOURCE', (resource,date,user_id,data), {}, (url,data), {}) + self.common_api_test('_COLLECTION_RESOURCE', (resource, date, user_id, data), {}, (url, data), {}) def test_body(self): # Test the first method defined in __init__ to see if it calls @@ -164,14 +213,18 @@ def test_impl(self): # _DELETE_COLLECTION_RESOURCE calls make_request with the right args resource = "RESOURCE" log_id = "Foo" - url = URLBASE + "/-/%s/%s.json" % (resource,log_id) - self.common_api_test('_DELETE_COLLECTION_RESOURCE', (resource, log_id), {}, - (url,), {"method": "DELETE"}) + url = URLBASE + "/-/%s/%s.json" % (resource, log_id) + self.common_api_test( + '_DELETE_COLLECTION_RESOURCE', + (resource, log_id), {}, + (url,), + {"method": "DELETE"} + ) def test_cant_delete_body(self): self.assertFalse(hasattr(self.fb, 'delete_body')) - def test_delete_water(self): + def test_delete_foods_log(self): log_id = "fake_log_id" # We need to mock _DELETE_COLLECTION_RESOURCE before we create the Fitbit object, # since the __init__ is going to set up references to it @@ -184,7 +237,7 @@ def test_delete_water(self): self.assertEqual({'log_id': log_id}, kwargs) self.assertEqual(999, retval) - def test_delete_water(self): + def test_delete_foods_log_water(self): log_id = "OmarKhayyam" # We need to mock _DELETE_COLLECTION_RESOURCE before we create the Fitbit object, # since the __init__ is going to set up references to it @@ -201,12 +254,12 @@ def test_delete_water(self): class ResourceAccessTest(TestBase): """ Class for testing the Fitbit Resource Access API: - https://wiki.fitbit.com/display/API/Fitbit+Resource+Access+API + https://dev.fitbit.com/docs/ """ def test_user_profile_get(self): """ Test getting a user profile. - https://wiki.fitbit.com/display/API/API-Get-User-Info + https://dev.fitbit.com/docs/user/ Tests the following HTTP method/URLs: GET https://api.fitbit.com/1/user/FOO/profile.json @@ -221,7 +274,7 @@ def test_user_profile_get(self): def test_user_profile_update(self): """ Test updating a user profile. - https://wiki.fitbit.com/display/API/API-Update-User-Info + https://dev.fitbit.com/docs/user/#update-profile Tests the following HTTP method/URLs: POST https://api.fitbit.com/1/user/-/profile.json @@ -250,6 +303,72 @@ def test_activity_stats_no_qualifier(self): qualifier = None self.common_api_test('activity_stats', (), dict(user_id=user_id, qualifier=qualifier), (URLBASE + "/%s/activities.json" % user_id,), {}) + def test_body_fat_goal(self): + self.common_api_test( + 'body_fat_goal', (), dict(), + (URLBASE + '/-/body/log/fat/goal.json',), {'data': {}}) + self.common_api_test( + 'body_fat_goal', (), dict(fat=10), + (URLBASE + '/-/body/log/fat/goal.json',), {'data': {'fat': 10}}) + + def test_body_weight_goal(self): + self.common_api_test( + 'body_weight_goal', (), dict(), + (URLBASE + '/-/body/log/weight/goal.json',), {'data': {}}) + self.common_api_test( + 'body_weight_goal', (), dict(start_date='2015-04-01', start_weight=180), + (URLBASE + '/-/body/log/weight/goal.json',), + {'data': {'startDate': '2015-04-01', 'startWeight': 180}}) + self.verify_raises('body_weight_goal', (), {'start_date': '2015-04-01'}, ValueError) + self.verify_raises('body_weight_goal', (), {'start_weight': 180}, ValueError) + + def test_activities_daily_goal(self): + self.common_api_test( + 'activities_daily_goal', (), dict(), + (URLBASE + '/-/activities/goals/daily.json',), {'data': {}}) + self.common_api_test( + 'activities_daily_goal', (), dict(steps=10000), + (URLBASE + '/-/activities/goals/daily.json',), {'data': {'steps': 10000}}) + self.common_api_test( + 'activities_daily_goal', (), + dict(calories_out=3107, active_minutes=30, floors=10, distance=5, steps=10000), + (URLBASE + '/-/activities/goals/daily.json',), + {'data': {'caloriesOut': 3107, 'activeMinutes': 30, 'floors': 10, 'distance': 5, 'steps': 10000}}) + + def test_activities_weekly_goal(self): + self.common_api_test( + 'activities_weekly_goal', (), dict(), + (URLBASE + '/-/activities/goals/weekly.json',), {'data': {}}) + self.common_api_test( + 'activities_weekly_goal', (), dict(steps=10000), + (URLBASE + '/-/activities/goals/weekly.json',), {'data': {'steps': 10000}}) + self.common_api_test( + 'activities_weekly_goal', (), + dict(floors=10, distance=5, steps=10000), + (URLBASE + '/-/activities/goals/weekly.json',), + {'data': {'floors': 10, 'distance': 5, 'steps': 10000}}) + + def test_food_goal(self): + self.common_api_test( + 'food_goal', (), dict(), + (URLBASE + '/-/foods/log/goal.json',), {'data': {}}) + self.common_api_test( + 'food_goal', (), dict(calories=2300), + (URLBASE + '/-/foods/log/goal.json',), {'data': {'calories': 2300}}) + self.common_api_test( + 'food_goal', (), dict(intensity='EASIER', personalized=True), + (URLBASE + '/-/foods/log/goal.json',), + {'data': {'intensity': 'EASIER', 'personalized': True}}) + self.verify_raises('food_goal', (), {'personalized': True}, ValueError) + + def test_water_goal(self): + self.common_api_test( + 'water_goal', (), dict(), + (URLBASE + '/-/foods/log/water/goal.json',), {'data': {}}) + self.common_api_test( + 'water_goal', (), dict(target=63), + (URLBASE + '/-/foods/log/water/goal.json',), {'data': {'target': 63}}) + def test_timeseries(self): resource = 'FOO' user_id = 'BAR' @@ -366,7 +485,7 @@ def _test_get_bodyweight(self, base_date=None, user_id=None, period=None, def test_bodyweight(self): """ Tests for retrieving body weight measurements. - https://wiki.fitbit.com/display/API/API-Get-Body-Weight + https://dev.fitbit.com/docs/body/#get-weight-logs Tests the following methods/URLs: GET https://api.fitbit.com/1/user/-/body/log/weight/date/1992-05-12.json GET https://api.fitbit.com/1/user/BAR/body/log/weight/date/1992-05-12/1998-12-31.json @@ -408,7 +527,7 @@ def _test_get_bodyfat(self, base_date=None, user_id=None, period=None, def test_bodyfat(self): """ Tests for retrieving bodyfat measurements. - https://wiki.fitbit.com/display/API/API-Get-Body-Fat + https://dev.fitbit.com/docs/body/#get-body-fat-logs Tests the following methods/URLs: GET https://api.fitbit.com/1/user/-/body/log/fat/date/1992-05-12.json GET https://api.fitbit.com/1/user/BAR/body/log/fat/date/1992-05-12/1998-12-31.json @@ -533,7 +652,7 @@ def test_alarms(self): class SubscriptionsTest(TestBase): """ Class for testing the Fitbit Subscriptions API: - https://wiki.fitbit.com/display/API/Fitbit+Subscriptions+API + https://dev.fitbit.com/docs/subscriptions/ """ def test_subscriptions(self): @@ -562,7 +681,7 @@ def test_subscriptions(self): class PartnerAPITest(TestBase): """ Class for testing the Fitbit Partner API: - https://wiki.fitbit.com/display/API/Fitbit+Partner+API + https://dev.fitbit.com/docs/ """ def _test_intraday_timeseries(self, resource, base_date, detail_level, @@ -577,7 +696,7 @@ def _test_intraday_timeseries(self, resource, base_date, detail_level, def test_intraday_timeseries(self): """ Intraday Time Series tests: - https://wiki.fitbit.com/display/API/API-Get-Intraday-Time-Series + https://dev.fitbit.com/docs/activity/#get-activity-intraday-time-series Tests the following methods/URLs: GET https://api.fitbit.com/1/user/-/FOO/date/1918-05-11/1d/1min.json @@ -647,12 +766,12 @@ def test_intraday_timeseries(self): # start_time can be a datetime object self._test_intraday_timeseries( resource, base_date=base_date, detail_level='1min', - start_time=datetime.time(3,56), end_time='15:07', + start_time=datetime.time(3, 56), end_time='15:07', expected_url=URLBASE + "/-/FOO/date/1918-05-11/1d/1min/time/03:56/15:07.json") # end_time can be a datetime object self._test_intraday_timeseries( resource, base_date=base_date, detail_level='1min', - start_time='3:56', end_time=datetime.time(15,7), + start_time='3:56', end_time=datetime.time(15, 7), expected_url=URLBASE + "/-/FOO/date/1918-05-11/1d/1min/time/3:56/15:07.json") # start_time can be a midnight datetime object self._test_intraday_timeseries( diff --git a/fitbit_tests/test_auth.py b/fitbit_tests/test_auth.py index e3ecca6..6bf7ab7 100644 --- a/fitbit_tests/test_auth.py +++ b/fitbit_tests/test_auth.py @@ -1,172 +1,186 @@ -from unittest import TestCase -from fitbit import Fitbit, FitbitOauthClient, FitbitOauth2Client +import copy +import json import mock -from requests_oauthlib import OAuth1Session, OAuth2Session -from oauthlib.oauth2 import TokenExpiredError +import requests_mock -class AuthTest(TestCase): - """Add tests for auth part of API - mock the oauth library calls to simulate various responses, - make sure we call the right oauth calls, respond correctly based on the responses - """ - client_kwargs = { - 'client_key': '', - 'client_secret': '', - 'user_key': None, - 'user_secret': None, - 'callback_uri': 'CALLBACK_URL' - } - - def test_fetch_request_token(self): - # fetch_request_token needs to make a request and then build a token from the response - - fb = Fitbit(**self.client_kwargs) - with mock.patch.object(OAuth1Session, 'fetch_request_token') as frt: - frt.return_value = { - 'oauth_callback_confirmed': 'true', - 'oauth_token': 'FAKE_OAUTH_TOKEN', - 'oauth_token_secret': 'FAKE_OAUTH_TOKEN_SECRET'} - retval = fb.client.fetch_request_token() - self.assertEqual(1, frt.call_count) - # Got the right return value - self.assertEqual('true', retval.get('oauth_callback_confirmed')) - self.assertEqual('FAKE_OAUTH_TOKEN', retval.get('oauth_token')) - self.assertEqual('FAKE_OAUTH_TOKEN_SECRET', - retval.get('oauth_token_secret')) - - def test_authorize_token_url(self): - # authorize_token_url calls oauth and returns a URL - fb = Fitbit(**self.client_kwargs) - with mock.patch.object(OAuth1Session, 'authorization_url') as au: - au.return_value = 'FAKEURL' - retval = fb.client.authorize_token_url() - self.assertEqual(1, au.call_count) - self.assertEqual("FAKEURL", retval) - - def test_authorize_token_url_with_parameters(self): - # authorize_token_url calls oauth and returns a URL - client = FitbitOauthClient(**self.client_kwargs) - retval = client.authorize_token_url(display="touch") - self.assertTrue("display=touch" in retval) +from datetime import datetime +from freezegun import freeze_time +from oauthlib.oauth2.rfc6749.errors import InvalidGrantError +from requests.auth import _basic_auth_str +from unittest import TestCase - def test_fetch_access_token(self): - kwargs = self.client_kwargs - kwargs['resource_owner_key'] = '' - kwargs['resource_owner_secret'] = '' - fb = Fitbit(**kwargs) - fake_verifier = "FAKEVERIFIER" - with mock.patch.object(OAuth1Session, 'fetch_access_token') as fat: - fat.return_value = { - 'encoded_user_id': 'FAKE_USER_ID', - 'oauth_token': 'FAKE_RETURNED_KEY', - 'oauth_token_secret': 'FAKE_RETURNED_SECRET' - } - retval = fb.client.fetch_access_token(fake_verifier) - self.assertEqual("FAKE_RETURNED_KEY", retval['oauth_token']) - self.assertEqual("FAKE_RETURNED_SECRET", retval['oauth_token_secret']) - self.assertEqual('FAKE_USER_ID', fb.client.user_id) +from fitbit import Fitbit class Auth2Test(TestCase): """Add tests for auth part of API mock the oauth library calls to simulate various responses, - make sure we call the right oauth calls, respond correctly based on the responses + make sure we call the right oauth calls, respond correctly based on the + responses """ client_kwargs = { - 'client_key': 'fake_id', + 'client_id': 'fake_id', 'client_secret': 'fake_secret', - 'callback_uri': 'fake_callback_url', - 'oauth2': True, + 'redirect_uri': 'http://127.0.0.1:8080', 'scope': ['fake_scope1'] } + def test_authorize_token_url(self): # authorize_token_url calls oauth and returns a URL fb = Fitbit(**self.client_kwargs) retval = fb.client.authorize_token_url() - self.assertEqual(retval[0],'https://www.fitbit.com/oauth2/authorize?response_type=code&client_id=fake_id&scope=activity+nutrition+heartrate+location+nutrition+profile+settings+sleep+social+weight&state='+retval[1]) + self.assertEqual(retval[0], 'https://www.fitbit.com/oauth2/authorize?response_type=code&client_id=fake_id&redirect_uri=http%3A%2F%2F127.0.0.1%3A8080&scope=activity+nutrition+heartrate+location+nutrition+profile+settings+sleep+social+weight&state='+retval[1]) - def test_authorize_token_url_with_parameters(self): + def test_authorize_token_url_with_scope(self): # authorize_token_url calls oauth and returns a URL fb = Fitbit(**self.client_kwargs) - retval = fb.client.authorize_token_url(scope=self.client_kwargs['scope'], - callback_uri=self.client_kwargs['callback_uri']) - self.assertEqual(retval[0],'https://www.fitbit.com/oauth2/authorize?response_type=code&client_id=fake_id&scope='+ str(self.client_kwargs['scope'][0])+ '&state='+retval[1]+'&callback_uri='+self.client_kwargs['callback_uri']) - + retval = fb.client.authorize_token_url(scope=self.client_kwargs['scope']) + self.assertEqual(retval[0], 'https://www.fitbit.com/oauth2/authorize?response_type=code&client_id=fake_id&redirect_uri=http%3A%2F%2F127.0.0.1%3A8080&scope='+ str(self.client_kwargs['scope'][0])+ '&state='+retval[1]) def test_fetch_access_token(self): # tests the fetching of access token using code and redirect_URL fb = Fitbit(**self.client_kwargs) fake_code = "fake_code" - with mock.patch.object(OAuth2Session, 'fetch_token') as fat: - fat.return_value = { + with requests_mock.mock() as m: + m.post(fb.client.access_token_url, text=json.dumps({ 'access_token': 'fake_return_access_token', 'refresh_token': 'fake_return_refresh_token' - } - retval = fb.client.fetch_access_token(fake_code, self.client_kwargs['callback_uri']) + })) + retval = fb.client.fetch_access_token(fake_code) self.assertEqual("fake_return_access_token", retval['access_token']) self.assertEqual("fake_return_refresh_token", retval['refresh_token']) - def test_refresh_token(self): # test of refresh function - kwargs = self.client_kwargs + kwargs = copy.copy(self.client_kwargs) kwargs['access_token'] = 'fake_access_token' kwargs['refresh_token'] = 'fake_refresh_token' + kwargs['refresh_cb'] = lambda x: None fb = Fitbit(**kwargs) - with mock.patch.object(OAuth2Session, 'post') as r: - r.return_value = fake_response(200,'{"access_token": "fake_return_access_token", "scope": "fake_scope", "token_type": "Bearer", "refresh_token": "fake_return_refresh_token"}') + with requests_mock.mock() as m: + m.post(fb.client.refresh_token_url, text=json.dumps({ + 'access_token': 'fake_return_access_token', + 'refresh_token': 'fake_return_refresh_token' + })) retval = fb.client.refresh_token() self.assertEqual("fake_return_access_token", retval['access_token']) self.assertEqual("fake_return_refresh_token", retval['refresh_token']) - - def test_auto_refresh_token_exception(self): - # test of auto_refersh with tokenExpired exception - # 1. first call to _request causes a TokenExpired + @freeze_time(datetime.fromtimestamp(1483563319)) + def test_auto_refresh_expires_at(self): + """Test of auto_refresh with expired token""" + # 1. first call to _request causes a HTTPUnauthorized # 2. the token_refresh call is faked # 3. the second call to _request returns a valid value - kwargs = self.client_kwargs - kwargs['access_token'] = 'fake_access_token' - kwargs['refresh_token'] = 'fake_refresh_token' + refresh_cb = mock.MagicMock() + kwargs = copy.copy(self.client_kwargs) + kwargs.update({ + 'access_token': 'fake_access_token', + 'refresh_token': 'fake_refresh_token', + 'expires_at': 1483530000, + 'refresh_cb': refresh_cb, + }) fb = Fitbit(**kwargs) - with mock.patch.object(FitbitOauth2Client, '_request') as r: - r.side_effect = [TokenExpiredError, fake_response(200,'correct_response')] - with mock.patch.object(OAuth2Session, 'post') as auth: - auth.return_value = fake_response(200,'{"access_token": "fake_return_access_token", "scope": "fake_scope", "token_type": "Bearer", "refresh_token": "fake_return_refresh_token"}') - retval = fb.client.make_request(Fitbit.API_ENDPOINT + '/1/user/-/profile.json') - self.assertEqual("correct_response", retval.text) - self.assertEqual("fake_return_access_token", fb.client.token['access_token']) - self.assertEqual("fake_return_refresh_token", fb.client.token['refresh_token']) - self.assertEqual(1, auth.call_count) - self.assertEqual(2, r.call_count) - - - def test_auto_refresh_token_nonException(self): - # test of auto_refersh when the exception doesn't fire - # 1. first call to _request causes a 401 expired token response + profile_url = Fitbit.API_ENDPOINT + '/1/user/-/profile.json' + with requests_mock.mock() as m: + m.get( + profile_url, + text='{"user":{"aboutMe": "python-fitbit developer"}}', + status_code=200 + ) + token = { + 'access_token': 'fake_return_access_token', + 'refresh_token': 'fake_return_refresh_token', + 'expires_at': 1483570000, + } + m.post(fb.client.refresh_token_url, text=json.dumps(token)) + retval = fb.make_request(profile_url) + + self.assertEqual(m.request_history[0].path, '/oauth2/token') + self.assertEqual( + m.request_history[0].headers['Authorization'], + _basic_auth_str( + self.client_kwargs['client_id'], + self.client_kwargs['client_secret'] + ) + ) + self.assertEqual(retval['user']['aboutMe'], "python-fitbit developer") + self.assertEqual("fake_return_access_token", token['access_token']) + self.assertEqual("fake_return_refresh_token", token['refresh_token']) + refresh_cb.assert_called_once_with(token) + + def test_auto_refresh_token_exception(self): + """Test of auto_refresh with Unauthorized exception""" + # 1. first call to _request causes a HTTPUnauthorized # 2. the token_refresh call is faked # 3. the second call to _request returns a valid value - kwargs = self.client_kwargs - kwargs['access_token'] = 'fake_access_token' - kwargs['refresh_token'] = 'fake_refresh_token' + refresh_cb = mock.MagicMock() + kwargs = copy.copy(self.client_kwargs) + kwargs.update({ + 'access_token': 'fake_access_token', + 'refresh_token': 'fake_refresh_token', + 'refresh_cb': refresh_cb, + }) + + fb = Fitbit(**kwargs) + profile_url = Fitbit.API_ENDPOINT + '/1/user/-/profile.json' + with requests_mock.mock() as m: + m.get(profile_url, [{ + 'text': json.dumps({ + "errors": [{ + "errorType": "expired_token", + "message": "Access token expired:" + }] + }), + 'status_code': 401 + }, { + 'text': '{"user":{"aboutMe": "python-fitbit developer"}}', + 'status_code': 200 + }]) + token = { + 'access_token': 'fake_return_access_token', + 'refresh_token': 'fake_return_refresh_token' + } + m.post(fb.client.refresh_token_url, text=json.dumps(token)) + retval = fb.make_request(profile_url) + + self.assertEqual(m.request_history[1].path, '/oauth2/token') + self.assertEqual( + m.request_history[1].headers['Authorization'], + _basic_auth_str( + self.client_kwargs['client_id'], + self.client_kwargs['client_secret'] + ) + ) + self.assertEqual(retval['user']['aboutMe'], "python-fitbit developer") + self.assertEqual("fake_return_access_token", token['access_token']) + self.assertEqual("fake_return_refresh_token", token['refresh_token']) + refresh_cb.assert_called_once_with(token) + + def test_auto_refresh_error(self): + """Test of auto_refresh with expired refresh token""" + + refresh_cb = mock.MagicMock() + kwargs = copy.copy(self.client_kwargs) + kwargs.update({ + 'access_token': 'fake_access_token', + 'refresh_token': 'fake_refresh_token', + 'refresh_cb': refresh_cb, + }) fb = Fitbit(**kwargs) - with mock.patch.object(FitbitOauth2Client, '_request') as r: - r.side_effect = [fake_response(401,b'{"errors": [{"message": "Access token invalid or expired: some_token_goes_here", "errorType": "oauth", "fieldName": "access_token"}]}'), - fake_response(200,'correct_response')] - with mock.patch.object(OAuth2Session, 'post') as auth: - auth.return_value = fake_response(200,'{"access_token": "fake_return_access_token", "scope": "fake_scope", "token_type": "Bearer", "refresh_token": "fake_return_refresh_token"}') - retval = fb.client.make_request(Fitbit.API_ENDPOINT + '/1/user/-/profile.json') - self.assertEqual("correct_response", retval.text) - self.assertEqual("fake_return_access_token", fb.client.token['access_token']) - self.assertEqual("fake_return_refresh_token", fb.client.token['refresh_token']) - self.assertEqual(1, auth.call_count) - self.assertEqual(2, r.call_count) + with requests_mock.mock() as m: + response = { + "errors": [{"errorType": "invalid_grant"}], + "success": False + } + m.post(fb.client.refresh_token_url, text=json.dumps(response)) + self.assertRaises(InvalidGrantError, fb.client.refresh_token) class fake_response(object): - def __init__(self,code,text): + def __init__(self, code, text): self.status_code = code self.text = text - self.content = text + self.content = text diff --git a/fitbit_tests/test_exceptions.py b/fitbit_tests/test_exceptions.py index 2b87e9a..d43b656 100644 --- a/fitbit_tests/test_exceptions.py +++ b/fitbit_tests/test_exceptions.py @@ -1,19 +1,21 @@ import unittest +import json import mock import requests import sys from fitbit import Fitbit from fitbit import exceptions + class ExceptionTest(unittest.TestCase): """ Tests that certain response codes raise certain exceptions """ client_kwargs = { - "client_key": "", + "client_id": "", "client_secret": "", - "user_key": None, - "user_secret": None, + "access_token": None, + "refresh_token": None } def test_response_ok(self): @@ -36,7 +38,6 @@ def test_response_ok(self): r.status_code = 204 f.user_profile_get() - def test_response_auth(self): """ This test checks how the client handles different auth responses, and @@ -44,7 +45,14 @@ def test_response_auth(self): """ r = mock.Mock(spec=requests.Response) r.status_code = 401 - r.content = b"{'normal': 'resource'}" + json_response = { + "errors": [{ + "errorType": "unauthorized", + "message": "Unknown auth error"} + ], + "normal": "resource" + } + r.content = json.dumps(json_response).encode('utf8') f = Fitbit(**self.client_kwargs) f.client._request = lambda *args, **kwargs: r @@ -52,16 +60,21 @@ def test_response_auth(self): self.assertRaises(exceptions.HTTPUnauthorized, f.user_profile_get) r.status_code = 403 + json_response['errors'][0].update({ + "errorType": "forbidden", + "message": "Forbidden" + }) + r.content = json.dumps(json_response).encode('utf8') self.assertRaises(exceptions.HTTPForbidden, f.user_profile_get) - def test_response_error(self): """ Tests other HTTP errors """ r = mock.Mock(spec=requests.Response) - r.content = b"{'normal': 'resource'}" + r.content = b'{"normal": "resource"}' + self.client_kwargs['oauth2'] = True f = Fitbit(**self.client_kwargs) f.client._request = lambda *args, **kwargs: r diff --git a/gather_keys_cli.py b/gather_keys_cli.py deleted file mode 100755 index c7b4523..0000000 --- a/gather_keys_cli.py +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env python -""" -This was taken, and modified from python-oauth2/example/client.py, -License reproduced below. - --------------------------- -The MIT License - -Copyright (c) 2007 Leah Culver - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - -Example consumer. This is not recommended for production. -Instead, you'll want to create your own subclass of OAuthClient -or find one that works with your web framework. -""" - -import os -import pprint -import sys -import webbrowser - -from fitbit.api import FitbitOauthClient - - -def gather_keys(): - # setup - pp = pprint.PrettyPrinter(indent=4) - print('** OAuth Python Library Example **\n') - client = FitbitOauthClient(CLIENT_KEY, CLIENT_SECRET) - - # get request token - print('* Obtain a request token ...\n') - token = client.fetch_request_token() - print('RESPONSE') - pp.pprint(token) - print('') - - print('* Authorize the request token in your browser\n') - stderr = os.dup(2) - os.close(2) - os.open(os.devnull, os.O_RDWR) - webbrowser.open(client.authorize_token_url()) - os.dup2(stderr, 2) - try: - verifier = raw_input('Verifier: ') - except NameError: - # Python 3.x - verifier = input('Verifier: ') - - # get access token - print('\n* Obtain an access token ...\n') - token = client.fetch_access_token(verifier) - print('RESPONSE') - pp.pprint(token) - print('') - - -if __name__ == '__main__': - if not (len(sys.argv) == 3): - print("Arguments 'client key', 'client secret' are required") - sys.exit(1) - CLIENT_KEY = sys.argv[1] - CLIENT_SECRET = sys.argv[2] - - gather_keys() - print('Done.') diff --git a/gather_keys_oauth2.py b/gather_keys_oauth2.py index 1060fc6..39a19f8 100755 --- a/gather_keys_oauth2.py +++ b/gather_keys_oauth2.py @@ -6,32 +6,45 @@ import traceback import webbrowser +from urllib.parse import urlparse from base64 import b64encode -from fitbit.api import FitbitOauth2Client +from fitbit.api import Fitbit from oauthlib.oauth2.rfc6749.errors import MismatchingStateError, MissingTokenError -from requests_oauthlib import OAuth2Session class OAuth2Server: def __init__(self, client_id, client_secret, redirect_uri='http://127.0.0.1:8080/'): """ Initialize the FitbitOauth2Client """ - self.redirect_uri = redirect_uri self.success_html = """

You are now authorized to access the Fitbit API!


You can close this window

""" self.failure_html = """

ERROR: %s


You can close this window

%s""" - self.oauth = FitbitOauth2Client(client_id, client_secret) + + self.fitbit = Fitbit( + client_id, + client_secret, + redirect_uri=redirect_uri, + timeout=10, + ) + + self.redirect_uri = redirect_uri def browser_authorize(self): """ Open a browser to the authorization url and spool up a CherryPy server to accept the response """ - url, _ = self.oauth.authorize_token_url(redirect_uri=self.redirect_uri) + url, _ = self.fitbit.client.authorize_token_url() # Open the web browser in a new thread for command-line browser support threading.Timer(1, webbrowser.open, args=(url,)).start() + + # Same with redirect_uri hostname and port. + urlparams = urlparse(self.redirect_uri) + cherrypy.config.update({'server.socket_host': urlparams.hostname, + 'server.socket_port': urlparams.port}) + cherrypy.quickstart(self) @cherrypy.expose @@ -43,7 +56,7 @@ def index(self, state, code=None, error=None): error = None if code: try: - self.oauth.fetch_access_token(code, self.redirect_uri) + self.fitbit.client.fetch_access_token(code) except MissingTokenError: error = self._fmt_failure( 'Missing access token parameter.
Please check that ' @@ -75,5 +88,11 @@ def _shutdown_cherrypy(self): server = OAuth2Server(*sys.argv[1:]) server.browser_authorize() - print('FULL RESULTS = %s' % server.oauth.token) - print('ACCESS_TOKEN = %s' % server.oauth.token['access_token']) + + profile = server.fitbit.user_profile_get() + print('You are authorized to access data for the user: {}'.format( + profile['user']['fullName'])) + + print('TOKEN\n=====\n') + for key, value in server.fitbit.client.session.token.items(): + print('{} = {}'.format(key, value)) diff --git a/requirements/base.txt b/requirements/base.txt index 93e4096..1331f7b 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,2 +1,2 @@ -python-dateutil>=1.5,<2.5 -requests-oauthlib>=0.4,<0.5 +python-dateutil>=1.5 +requests-oauthlib>=0.7 diff --git a/requirements/dev.txt b/requirements/dev.txt index ce26865..27e4b56 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,5 +1,5 @@ -r base.txt -r test.txt -cherrypy>=3.7,<3.8 -tox>=1.8,<1.9 +cherrypy>=3.7,<3.9 +tox>=1.8,<2.2 diff --git a/requirements/test.txt b/requirements/test.txt index 90279ae..711c52b 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -1,3 +1,5 @@ -mock>=1.0,<1.1 -coverage>=3.7,<3.8 -Sphinx>=1.2,<1.3 +coverage>=3.7,<4.0 +freezegun>=0.3.8 +mock>=1.0 +requests-mock>=1.2.0 +Sphinx>=1.2,<1.4 diff --git a/setup.py b/setup.py index 8dbbdb4..f5c4453 100644 --- a/setup.py +++ b/setup.py @@ -5,8 +5,8 @@ from setuptools import setup -required = [line for line in open('requirements/base.txt').read().split("\n")] -required_test = [line for line in open('requirements/test.txt').read().split("\n") if not line.startswith("-r")] +required = [line for line in open('requirements/base.txt').read().split("\n") if line != ''] +required_test = [line for line in open('requirements/test.txt').read().split("\n") if not line.startswith("-r") and line != ''] fbinit = open('fitbit/__init__.py').read() author = re.search("__author__ = '([^']+)'", fbinit).group(1) @@ -33,12 +33,11 @@ 'Natural Language :: English', 'License :: OSI Approved :: Apache Software License', 'Programming Language :: Python', - 'Programming Language :: Python :: 2.6', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.2', - 'Programming Language :: Python :: 3.3', 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: Implementation :: PyPy' ), ) diff --git a/tox.ini b/tox.ini index 5b824df..71533b0 100644 --- a/tox.ini +++ b/tox.ini @@ -1,28 +1,8 @@ [tox] -envlist = pypy,py34,py33,py32,py27,py26,docs +envlist = pypy-test,pypy3-test,py36-test,py35-test,py34-test,py27-test,py36-docs [testenv] -commands = coverage run --source=fitbit setup.py test +commands = + test: coverage run --source=fitbit setup.py test + docs: sphinx-build -W -b html docs docs/_build deps = -r{toxinidir}/requirements/test.txt - -[testenv:pypy] -basepython = pypy - -[testenv:py34] -basepython = python3.4 - -[testenv:py33] -basepython = python3.3 - -[testenv:py32] -basepython = python3.2 - -[testenv:py27] -basepython = python2.7 - -[testenv:py26] -basepython = python2.6 - -[testenv:docs] -basepython = python3.4 -commands = sphinx-build -W -b html docs docs/_build