From 6ad35fc1fe8906603f71b370f1a0561a747aea0a Mon Sep 17 00:00:00 2001 From: Jamie Williams Date: Tue, 30 Sep 2014 22:27:15 -0500 Subject: [PATCH 01/87] added intraday_time_series method with basics tests --- fitbit/api.py | 55 +++++++++++++++++++++++++++++++++ fitbit_tests/test_api.py | 66 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) diff --git a/fitbit/api.py b/fitbit/api.py index 48b72e4..2d8ba80 100644 --- a/fitbit/api.py +++ b/fitbit/api.py @@ -362,6 +362,61 @@ def time_series(self, resource, user_id=None, base_date='today', ) return self.make_request(url) + def intraday_time_series(self, resource, base_date='today', end_date=None, detail_level='1min', start_time=None, end_time=None): + """ + The intraday time series extends the functionality of the regular time series, but returning data at a + more granular level, 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: + + https://wiki.fitbit.com/display/API/API-Get-Intraday-Time-Series + """ + + if start_time and not end_time: + raise TypeError("You must provide an end time when you provide a start time") + + if end_time and not start_time: + raise TypeError("You must provide a start time when you provide an end time") + + if end_date: + if not isinstance(end_date, str): + date_fin = end_date.strftime('%Y-%m-%d') + else: + date_fin = end_date + else: + date_fin = '1d' + + if not isinstance(base_date, str): + base_date = base_date.strftime('%Y-%m-%d') + + if not detail_level in ['1min', '15min']: + raise ValueError("Period must be either '1min' or '15min'") + + url = "%s/%s/user/-/%s/date/%s/%s/%s" % ( + self.API_ENDPOINT, + self.API_VERSION, + resource, + base_date, + date_fin, + detail_level + ) + + if start_time: + time_init = start_time + if not isinstance(time_init, str): + time_init = start_time.strftime('%H:%M') + url = url + ('/%s' % (time_init)) + + if end_time: + time_fin = end_time + if not isinstance(time_fin, str): + time_fin = time_fin.strftime('%H:%M') + url = url + ('/%s' % (time_fin)) + + url = url + '.json' + + return self.make_request(url) + def activity_stats(self, user_id=None, qualifier=''): """ * https://wiki.fitbit.com/display/API/API-Get-Activity-Stats diff --git a/fitbit_tests/test_api.py b/fitbit_tests/test_api.py index 4b93142..65501b9 100644 --- a/fitbit_tests/test_api.py +++ b/fitbit_tests/test_api.py @@ -257,6 +257,72 @@ def test_timeseries(fb, resource, user_id, base_date, period, end_date, expected test_timeseries(self.fb, resource, user_id=user_id, base_date=datetime.date(1992,5,12), period=None, end_date=end_date, expected_url=URLBASE + "/BAR/FOO/date/1992-05-12/1998-12-31.json") + def test_intraday_timeseries(self): + resource = 'FOO' + base_date = '1918-05-11' + end_date = '1988-02-15' + + # detail_level must be valid + self.assertRaises( + ValueError, + self.fb.intraday_time_series, + resource, + base_date, + end_date=None, + detail_level="xyz", + start_time=None, + end_time=None) + + # provide end_time if start_time provided + self.assertRaises( + TypeError, + self.fb.intraday_time_series, + resource, + base_date, + end_date=None, + detail_level="1min", + start_time='12:55', + end_time=None) + + # provide start_time if end_time provided + self.assertRaises( + TypeError, + self.fb.intraday_time_series, + resource, + base_date, + end_date=None, + detail_level="1min", + start_time=None, + end_time='12:55') + + def test_intraday_timeseries(fb, resource, base_date, end_date, detail_level, start_time, end_time, expected_url): + with mock.patch.object(fb, 'make_request') as make_request: + retval = fb.intraday_time_series(resource, base_date, end_date, detail_level, start_time, end_time) + args, kwargs = make_request.call_args + self.assertEqual((expected_url,), args) + + # Default + test_intraday_timeseries(self.fb, resource, base_date=base_date, + end_date=None, detail_level='1min', start_time=None, end_time=None, + expected_url=URLBASE + "/-/FOO/date/1918-05-11/1d/1min.json") + # end_date can be a date object + test_intraday_timeseries(self.fb, resource, base_date=base_date, + end_date=datetime.date(1988, 2, 15), detail_level='15min', start_time=None, end_time=None, + expected_url=URLBASE + "/-/FOO/date/1918-05-11/1988-02-15/15min.json") + # start_date can be a date object + test_intraday_timeseries(self.fb, resource, base_date=datetime.date(1918, 5, 11), + end_date=end_date, detail_level='1min', start_time=None, end_time=None, + expected_url=URLBASE + "/-/FOO/date/1918-05-11/1988-02-15/1min.json") + # start_time can be a datetime object + test_intraday_timeseries(self.fb, resource, base_date=base_date, + end_date=end_date, detail_level='1min', start_time=datetime.time(3,56), end_time='15:07', + expected_url=URLBASE + "/-/FOO/date/1918-05-11/1988-02-15/1min/03:56/15:07.json") + # end_time can be a datetime object + test_intraday_timeseries(self.fb, resource, base_date=base_date, + end_date=end_date, detail_level='1min', start_time='3:56', end_time=datetime.time(15,7), + expected_url=URLBASE + "/-/FOO/date/1918-05-11/1988-02-15/1min/3:56/15:07.json") + + def test_foods(self): today = datetime.date.today().strftime('%Y-%m-%d') self.common_api_test('recent_foods', ("USER_ID",), {}, (URLBASE+"/USER_ID/foods/log/recent.json",), {}) From c4ea5b461ed368b46c147b82f7d655aca976ecb6 Mon Sep 17 00:00:00 2001 From: Jamie Williams Date: Sun, 5 Oct 2014 15:53:29 -0500 Subject: [PATCH 02/87] removed end_date, since intradate time series only works for a single day --- fitbit/api.py | 19 +++++-------------- fitbit_tests/test_api.py | 26 +++++++++----------------- 2 files changed, 14 insertions(+), 31 deletions(-) diff --git a/fitbit/api.py b/fitbit/api.py index 2d8ba80..ee389e7 100644 --- a/fitbit/api.py +++ b/fitbit/api.py @@ -362,12 +362,12 @@ def time_series(self, resource, user_id=None, base_date='today', ) return self.make_request(url) - def intraday_time_series(self, resource, base_date='today', end_date=None, detail_level='1min', start_time=None, end_time=None): + def intraday_time_series(self, resource, base_date='today', detail_level='1min', start_time=None, end_time=None): """ The intraday time series extends the functionality of the regular time series, but returning data at a - more granular level, 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: + 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: https://wiki.fitbit.com/display/API/API-Get-Intraday-Time-Series """ @@ -378,26 +378,17 @@ def intraday_time_series(self, resource, base_date='today', end_date=None, detai if end_time and not start_time: raise TypeError("You must provide a start time when you provide an end time") - if end_date: - if not isinstance(end_date, str): - date_fin = end_date.strftime('%Y-%m-%d') - else: - date_fin = end_date - else: - date_fin = '1d' - if not isinstance(base_date, str): base_date = base_date.strftime('%Y-%m-%d') if not detail_level in ['1min', '15min']: raise ValueError("Period must be either '1min' or '15min'") - url = "%s/%s/user/-/%s/date/%s/%s/%s" % ( + url = "%s/%s/user/-/%s/date/%s/1d/%s" % ( self.API_ENDPOINT, self.API_VERSION, resource, base_date, - date_fin, detail_level ) diff --git a/fitbit_tests/test_api.py b/fitbit_tests/test_api.py index 65501b9..2847f46 100644 --- a/fitbit_tests/test_api.py +++ b/fitbit_tests/test_api.py @@ -260,7 +260,6 @@ def test_timeseries(fb, resource, user_id, base_date, period, end_date, expected def test_intraday_timeseries(self): resource = 'FOO' base_date = '1918-05-11' - end_date = '1988-02-15' # detail_level must be valid self.assertRaises( @@ -268,7 +267,6 @@ def test_intraday_timeseries(self): self.fb.intraday_time_series, resource, base_date, - end_date=None, detail_level="xyz", start_time=None, end_time=None) @@ -279,7 +277,6 @@ def test_intraday_timeseries(self): self.fb.intraday_time_series, resource, base_date, - end_date=None, detail_level="1min", start_time='12:55', end_time=None) @@ -290,37 +287,32 @@ def test_intraday_timeseries(self): self.fb.intraday_time_series, resource, base_date, - end_date=None, detail_level="1min", start_time=None, end_time='12:55') - def test_intraday_timeseries(fb, resource, base_date, end_date, detail_level, start_time, end_time, expected_url): + def test_intraday_timeseries(fb, resource, base_date, detail_level, start_time, end_time, expected_url): with mock.patch.object(fb, 'make_request') as make_request: - retval = fb.intraday_time_series(resource, base_date, end_date, detail_level, start_time, end_time) + retval = fb.intraday_time_series(resource, base_date, detail_level, start_time, end_time) args, kwargs = make_request.call_args self.assertEqual((expected_url,), args) # Default test_intraday_timeseries(self.fb, resource, base_date=base_date, - end_date=None, detail_level='1min', start_time=None, end_time=None, + detail_level='1min', start_time=None, end_time=None, expected_url=URLBASE + "/-/FOO/date/1918-05-11/1d/1min.json") - # end_date can be a date object - test_intraday_timeseries(self.fb, resource, base_date=base_date, - end_date=datetime.date(1988, 2, 15), detail_level='15min', start_time=None, end_time=None, - expected_url=URLBASE + "/-/FOO/date/1918-05-11/1988-02-15/15min.json") # start_date can be a date object test_intraday_timeseries(self.fb, resource, base_date=datetime.date(1918, 5, 11), - end_date=end_date, detail_level='1min', start_time=None, end_time=None, - expected_url=URLBASE + "/-/FOO/date/1918-05-11/1988-02-15/1min.json") + detail_level='1min', start_time=None, end_time=None, + expected_url=URLBASE + "/-/FOO/date/1918-05-11/1d/1min.json") # start_time can be a datetime object test_intraday_timeseries(self.fb, resource, base_date=base_date, - end_date=end_date, detail_level='1min', start_time=datetime.time(3,56), end_time='15:07', - expected_url=URLBASE + "/-/FOO/date/1918-05-11/1988-02-15/1min/03:56/15:07.json") + detail_level='1min', start_time=datetime.time(3,56), end_time='15:07', + expected_url=URLBASE + "/-/FOO/date/1918-05-11/1d/1min/03:56/15:07.json") # end_time can be a datetime object test_intraday_timeseries(self.fb, resource, base_date=base_date, - end_date=end_date, detail_level='1min', start_time='3:56', end_time=datetime.time(15,7), - expected_url=URLBASE + "/-/FOO/date/1918-05-11/1988-02-15/1min/3:56/15:07.json") + detail_level='1min', start_time='3:56', end_time=datetime.time(15,7), + expected_url=URLBASE + "/-/FOO/date/1918-05-11/1d/1min/3:56/15:07.json") def test_foods(self): From c028716fa3b64ba690b1b0e969c064478bb83fd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bengt=20L=C3=BCers?= Date: Sun, 1 Feb 2015 18:40:51 +0100 Subject: [PATCH 03/87] Update README.rst This should close #50. --- README.rst | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/README.rst b/README.rst index 0e9d667..d0da4ee 100644 --- a/README.rst +++ b/README.rst @@ -19,3 +19,20 @@ Requirements ============ * Python 2.6+ +* [python-dateutil](https://pypi.python.org/pypi/python-dateutil/2.4.0) (always) +* [requests-oauthlib](https://pypi.python.org/pypi/requests-oauthlib) (always) +* [Sphinx](https://pypi.python.org/pypi/Sphinx) (to create the documention) +* [tox](https://pypi.python.org/pypi/tox) (for running the tests) +* [coverage](https://pypi.python.org/pypi/coverage/) (to create test coverage reports) + +To use the library, you need to install the run time requirements: + + sudo pip install -r requirements/base.txt + +To modify and test the library, you need to install the developer requirements: + + sudo pip install -r requirements/dev.txt + +To run the library on a continuous integration server, you need to install the test requirements: + + sudo pip install -r requirements/test.txt From 32a003cdb6d74e452db68b15a3d74053bc82bd2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bengt=20L=C3=BCers?= Date: Sun, 1 Feb 2015 18:47:14 +0100 Subject: [PATCH 04/87] fix link syntax --- README.rst | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/README.rst b/README.rst index d0da4ee..e817bb7 100644 --- a/README.rst +++ b/README.rst @@ -19,11 +19,17 @@ Requirements ============ * Python 2.6+ -* [python-dateutil](https://pypi.python.org/pypi/python-dateutil/2.4.0) (always) -* [requests-oauthlib](https://pypi.python.org/pypi/requests-oauthlib) (always) -* [Sphinx](https://pypi.python.org/pypi/Sphinx) (to create the documention) -* [tox](https://pypi.python.org/pypi/tox) (for running the tests) -* [coverage](https://pypi.python.org/pypi/coverage/) (to create test coverage reports) +* `python-dateutil`_ (always) +* `requests-oauthlib`_ (always) +* `Sphinx`_ (to create the documention) +* `tox`_ (for running the tests) +* `coverage`_ (to create test coverage reports) + +.. _python-dateutil: https://pypi.python.org/pypi/python-dateutil/2.4.0 +.. _requests-oauthlib: https://pypi.python.org/pypi/requests-oauthlib) +.. _Sphinx: https://pypi.python.org/pypi/Sphinx +.. _tox: https://pypi.python.org/pypi/tox +.. _coverage: https://pypi.python.org/pypi/coverage/ To use the library, you need to install the run time requirements: From 11988794c5858ac91579079c87ac8370b9a1a560 Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Sun, 1 Feb 2015 18:31:36 -0800 Subject: [PATCH 05/87] update copyright year --- LICENSE | 2 +- docs/conf.py | 2 +- fitbit/__init__.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/LICENSE b/LICENSE index 0d95e9a..eb83cdf 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright 2012-2014 ORCAS +Copyright 2012-2015 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/docs/conf.py b/docs/conf.py index cd95ecc..3700b7d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -41,7 +41,7 @@ # General information about the project. project = u'Python-Fitbit' -copyright = u'Copyright 2014 ORCAS' +copyright = u'Copyright 2012-2015 ORCAS' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the diff --git a/fitbit/__init__.py b/fitbit/__init__.py index f100b8a..fef2639 100644 --- a/fitbit/__init__.py +++ b/fitbit/__init__.py @@ -3,7 +3,7 @@ Fitbit API Library ------------------ -:copyright: 2012-2014 ORCAS. +:copyright: 2012-2015 ORCAS. :license: BSD, see LICENSE for more details. """ @@ -14,7 +14,7 @@ __title__ = 'fitbit' __author__ = 'Issac Kelly and ORCAS' __author_email__ = 'bpitcher@orcasinc.com' -__copyright__ = 'Copyright 2012-2014 ORCAS' +__copyright__ = 'Copyright 2012-2015 ORCAS' __license__ = 'Apache 2.0' __version__ = '0.1.2' From a29c1d087f3aa3aa21a3073ba351aed464b09892 Mon Sep 17 00:00:00 2001 From: Rich Lane Date: Mon, 2 Feb 2015 00:45:40 -0800 Subject: [PATCH 06/87] use a requests session This enables connection pooling, so we don't have to do a TCP and SSL handshake for every API call. --- fitbit/api.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/fitbit/api.py b/fitbit/api.py index 48b72e4..de6e679 100644 --- a/fitbit/api.py +++ b/fitbit/api.py @@ -43,6 +43,7 @@ def __init__(self, client_key, client_secret, resource_owner_key=None, 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 @@ -61,7 +62,7 @@ def _request(self, method, url, **kwargs): """ A simple wrapper around requests. """ - return requests.request(method, url, **kwargs) + return self.session.request(method, url, **kwargs) def make_request(self, url, data={}, method=None, **kwargs): """ From 60d16abd152b1707d0aa0e25605a9b6bc6e4c68b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bengt=20L=C3=BCers?= Date: Tue, 3 Feb 2015 21:07:55 +0100 Subject: [PATCH 07/87] remove superflous bracket --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index e817bb7..ff23090 100644 --- a/README.rst +++ b/README.rst @@ -26,7 +26,7 @@ Requirements * `coverage`_ (to create test coverage reports) .. _python-dateutil: https://pypi.python.org/pypi/python-dateutil/2.4.0 -.. _requests-oauthlib: https://pypi.python.org/pypi/requests-oauthlib) +.. _requests-oauthlib: https://pypi.python.org/pypi/requests-oauthlib .. _Sphinx: https://pypi.python.org/pypi/Sphinx .. _tox: https://pypi.python.org/pypi/tox .. _coverage: https://pypi.python.org/pypi/coverage/ From bf016465fcd2bd45f8869a0d8d4d07c7bfe9e736 Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Wed, 4 Feb 2015 19:10:58 -0800 Subject: [PATCH 08/87] fix #14, close #41 add /time/ to timeseries URL --- fitbit/api.py | 2 +- fitbit_tests/test_api.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/fitbit/api.py b/fitbit/api.py index ee389e7..0a27a8e 100644 --- a/fitbit/api.py +++ b/fitbit/api.py @@ -396,7 +396,7 @@ def intraday_time_series(self, resource, base_date='today', detail_level='1min', time_init = start_time if not isinstance(time_init, str): time_init = start_time.strftime('%H:%M') - url = url + ('/%s' % (time_init)) + url = url + ('/time/%s' % (time_init)) if end_time: time_fin = end_time diff --git a/fitbit_tests/test_api.py b/fitbit_tests/test_api.py index 2847f46..bef4aa0 100644 --- a/fitbit_tests/test_api.py +++ b/fitbit_tests/test_api.py @@ -308,11 +308,11 @@ def test_intraday_timeseries(fb, resource, base_date, detail_level, start_time, # start_time can be a datetime object test_intraday_timeseries(self.fb, resource, base_date=base_date, detail_level='1min', start_time=datetime.time(3,56), end_time='15:07', - expected_url=URLBASE + "/-/FOO/date/1918-05-11/1d/1min/03:56/15:07.json") + expected_url=URLBASE + "/-/FOO/date/1918-05-11/1d/1min/time/03:56/15:07.json") # end_time can be a datetime object test_intraday_timeseries(self.fb, resource, base_date=base_date, detail_level='1min', start_time='3:56', end_time=datetime.time(15,7), - expected_url=URLBASE + "/-/FOO/date/1918-05-11/1d/1min/3:56/15:07.json") + expected_url=URLBASE + "/-/FOO/date/1918-05-11/1d/1min/time/3:56/15:07.json") def test_foods(self): From eb5c08f6ad526ea9460c93a4b0069ec483076d8a Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Wed, 4 Feb 2015 20:16:41 -0800 Subject: [PATCH 09/87] piece together a CHANGELOG from history --- CHANGELOG.rst | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 CHANGELOG.rst diff --git a/CHANGELOG.rst b/CHANGELOG.rst new file mode 100644 index 0000000..b599907 --- /dev/null +++ b/CHANGELOG.rst @@ -0,0 +1,48 @@ +0.1.2 (2014-09-19) +================== + +* Quick fix for response objects without a status code + +0.1.1 (2014-09-18) +================== + +* Fix the broken foods log date endpoint +* Integrate with travis-ci.org, coveralls.io, and requires.io +* Add HTTPTooManyRequests exception with retry_after_secs information +* Enable adding parameters to authorize token URL + +0.1.0 (2014-04-15) +================== + +* Officially test/support Python 3.2+ and PyPy in addition to Python 2.x +* Clean up OAuth workflow, change the API slightly to match oauthlib terminology +* Fix some minor bugs + +0.0.5 (2014-03-30) +================== + +* Switch from python-oauth2 to the better supported oauthlib +* Add get_bodyweight and get_bodyfat methods + +0.0.3 (2014-02-05) +================== + +* Add get_badges method +* Include error messages in the exception +* Add API for alarms +* Add API for log activity +* Correctly pass headers on requests +* Way more test coverage +* Publish to PyPI + +0.0.2 (2012-10-02) +================== + +* Add docs, including Readthedocs support +* Add tests +* Use official oauth2 version from pypi + +0.0.1 (2012-02-25) +================== + +* Initial release From b5188b1f336438a4986ad7d6f2a0446440c7c239 Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Wed, 4 Feb 2015 20:22:33 -0800 Subject: [PATCH 10/87] version 0.1.3 --- CHANGELOG.rst | 6 ++++++ docs/conf.py | 4 ++-- fitbit/__init__.py | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b599907..9724eb7 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,9 @@ +0.1.3 (2015-02-04) +================== + +* Support Intraday Time Series API +* Use connection pooling to avoid a TCP and SSL handshake for every API call + 0.1.2 (2014-09-19) ================== diff --git a/docs/conf.py b/docs/conf.py index 3700b7d..205c641 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -48,9 +48,9 @@ # built documents. # # The short X.Y version. -version = '0.1' +version = '0.1.3' # The full version, including alpha/beta/rc tags. -release = '0.1.0' +release = '0.1.3' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/fitbit/__init__.py b/fitbit/__init__.py index fef2639..9d37ed0 100644 --- a/fitbit/__init__.py +++ b/fitbit/__init__.py @@ -17,7 +17,7 @@ __copyright__ = 'Copyright 2012-2015 ORCAS' __license__ = 'Apache 2.0' -__version__ = '0.1.2' +__version__ = '0.1.3' # Module namespace. From abc514c0a9a26ae8566378f68a507d19a5bd474b Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Tue, 17 Feb 2015 19:51:41 -0800 Subject: [PATCH 11/87] use requirement ranges --- requirements/base.txt | 4 ++-- requirements/dev.txt | 4 ++-- requirements/test.txt | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/requirements/base.txt b/requirements/base.txt index f5d86ee..8414d09 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,2 +1,2 @@ -python-dateutil>=1.5 -requests-oauthlib>=0.4.0 +python-dateutil>=1.5,<1.6 +requests-oauthlib>=0.4,<0.5 diff --git a/requirements/dev.txt b/requirements/dev.txt index 491e39f..07a8f0c 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,5 +1,5 @@ -r base.txt -r test.txt -Sphinx==1.2.3 -tox==1.8.1 +Sphinx>=1.2,<1.3 +tox>=1.8,<1.9 diff --git a/requirements/test.txt b/requirements/test.txt index 969f7a2..1623a6a 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -1,2 +1,2 @@ -mock==1.0.1 -coverage==3.7.1 +mock>=1.0,<1.1 +coverage>=3.7,<3.8 From 51a38e060016d82941fbbda1c391778a91575e42 Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Tue, 17 Feb 2015 20:00:57 -0800 Subject: [PATCH 12/87] add documentation building to the test matrix --- requirements/dev.txt | 1 - requirements/test.txt | 1 + tox.ini | 6 +++++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index 07a8f0c..4ec8b19 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,5 +1,4 @@ -r base.txt -r test.txt -Sphinx>=1.2,<1.3 tox>=1.8,<1.9 diff --git a/requirements/test.txt b/requirements/test.txt index 1623a6a..90279ae 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -1,2 +1,3 @@ mock>=1.0,<1.1 coverage>=3.7,<3.8 +Sphinx>=1.2,<1.3 diff --git a/tox.ini b/tox.ini index e2f8462..5b824df 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = pypy,py34,py33,py32,py27,py26 +envlist = pypy,py34,py33,py32,py27,py26,docs [testenv] commands = coverage run --source=fitbit setup.py test @@ -22,3 +22,7 @@ basepython = python2.7 [testenv:py26] basepython = python2.6 + +[testenv:docs] +basepython = python3.4 +commands = sphinx-build -W -b html docs docs/_build From d641919131c33d0a0b291071bc21d744c9240e2c Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Tue, 17 Feb 2015 20:01:37 -0800 Subject: [PATCH 13/87] add source links to the docs --- docs/conf.py | 14 +++++++++----- fitbit/__init__.py | 1 + 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 205c641..3e05f59 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -25,7 +25,10 @@ # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc'] +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.viewcode' +] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -40,17 +43,18 @@ master_doc = 'index' # General information about the project. +import fitbit project = u'Python-Fitbit' -copyright = u'Copyright 2012-2015 ORCAS' +copyright = fitbit.__copyright__ # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. -version = '0.1.3' +version = fitbit.__version__ # The full version, including alpha/beta/rc tags. -release = '0.1.3' +release = fitbit.__release__ # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -120,7 +124,7 @@ # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = [] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. diff --git a/fitbit/__init__.py b/fitbit/__init__.py index 9d37ed0..ccd7d36 100644 --- a/fitbit/__init__.py +++ b/fitbit/__init__.py @@ -18,6 +18,7 @@ __license__ = 'Apache 2.0' __version__ = '0.1.3' +__release__ = '0.1.3' # Module namespace. From 21c5cea4560634f5a24561e3ceb275ae047515f3 Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Tue, 17 Feb 2015 21:28:45 -0800 Subject: [PATCH 14/87] update travis test matrix --- .travis.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index f1c6347..1ad4a10 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,11 +1,12 @@ language: python -python: 3.3 +python: 3.4 env: - TOX_ENV=pypy - TOX_ENV=py33 - TOX_ENV=py32 - TOX_ENV=py27 - TOX_ENV=py26 + - TOX_ENV=docs install: - pip install coveralls tox script: tox -e $TOX_ENV From f5073d55d0104d3703c6c611a6a957e068438573 Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Tue, 17 Feb 2015 21:46:03 -0800 Subject: [PATCH 15/87] increase upper end of dateutil req range --- requirements/base.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/base.txt b/requirements/base.txt index 8414d09..93e4096 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,2 +1,2 @@ -python-dateutil>=1.5,<1.6 +python-dateutil>=1.5,<2.5 requests-oauthlib>=0.4,<0.5 From 4955b71491ce4894b8245db6e6ab5e96f4f6c431 Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Fri, 20 Feb 2015 21:58:01 -0800 Subject: [PATCH 16/87] fix water GET and water/food DELETE --- fitbit/api.py | 13 +++++++------ fitbit_tests/test_api.py | 17 +++++++++++++++-- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/fitbit/api.py b/fitbit/api.py index 06a83d4..17b4954 100644 --- a/fitbit/api.py +++ b/fitbit/api.py @@ -154,7 +154,7 @@ class Fitbit(object): 'body', 'activities', 'foods/log', - 'water', + 'foods/log/water', 'sleep', 'heart', 'bp', @@ -175,12 +175,13 @@ def __init__(self, client_key, client_secret, system=US, **kwargs): # creating and deleting records once, and use curry to make individual # Methods for each for resource in self._resource_list: - setattr(self, resource.replace('/', '_'), + underscore_resource = resource.replace('/', '_') + setattr(self, underscore_resource, curry(self._COLLECTION_RESOURCE, resource)) if resource not in ['body', 'glucose']: # Body and Glucose entries are not currently able to be deleted - setattr(self, 'delete_%s' % resource, curry( + setattr(self, 'delete_%s' % underscore_resource, curry( self._DELETE_COLLECTION_RESOURCE, resource)) for qualifier in self._qualifiers: @@ -261,7 +262,7 @@ def _COLLECTION_RESOURCE(self, resource, date=None, user_id=None, body(date=None, user_id=None, data=None) activities(date=None, user_id=None, data=None) foods_log(date=None, user_id=None, data=None) - water(date=None, user_id=None, data=None) + foods_log_water(date=None, user_id=None, data=None) sleep(date=None, user_id=None, data=None) heart(date=None, user_id=None, data=None) bp(date=None, user_id=None, data=None) @@ -306,8 +307,8 @@ def _DELETE_COLLECTION_RESOURCE(self, resource, log_id): delete_body(log_id) delete_activities(log_id) - delete_foods(log_id) - delete_water(log_id) + delete_foods_log(log_id) + delete_foods_log_water(log_id) delete_sleep(log_id) delete_heart(log_id) delete_bp(log_id) diff --git a/fitbit_tests/test_api.py b/fitbit_tests/test_api.py index bef4aa0..11837e6 100644 --- a/fitbit_tests/test_api.py +++ b/fitbit_tests/test_api.py @@ -175,6 +175,19 @@ def test_impl(self): def test_cant_delete_body(self): self.assertFalse(hasattr(self.fb, 'delete_body')) + def test_delete_water(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 + with mock.patch('fitbit.api.Fitbit._DELETE_COLLECTION_RESOURCE') as delete_resource: + delete_resource.return_value = 999 + fb = Fitbit('x', 'y') + retval = fb.delete_foods_log(log_id=log_id) + args, kwargs = delete_resource.call_args + self.assertEqual(('foods/log',), args) + self.assertEqual({'log_id': log_id}, kwargs) + self.assertEqual(999, retval) + def test_delete_water(self): log_id = "OmarKhayyam" # We need to mock _DELETE_COLLECTION_RESOURCE before we create the Fitbit object, @@ -182,9 +195,9 @@ def test_delete_water(self): with mock.patch('fitbit.api.Fitbit._DELETE_COLLECTION_RESOURCE') as delete_resource: delete_resource.return_value = 999 fb = Fitbit('x', 'y') - retval = fb.delete_water(log_id=log_id) + retval = fb.delete_foods_log_water(log_id=log_id) args, kwargs = delete_resource.call_args - self.assertEqual(('water',), args) + self.assertEqual(('foods/log/water',), args) self.assertEqual({'log_id': log_id}, kwargs) self.assertEqual(999, retval) From 05922b15d5465b1dfa645101f348e7aca65f6c93 Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Sat, 21 Feb 2015 10:15:43 -0800 Subject: [PATCH 17/87] update quickstart docs, fixes #15 --- docs/index.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index a237953..e3570f3 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -19,9 +19,10 @@ Here is some example usage:: import fitbit unauth_client = fitbit.Fitbit('', '') # certain methods do not require user keys - unauth_client.activities() + unauth_client.food_units() - # You'll have to gather the user keys on your own, or try ./fitbit/gather_keys_cli.py for development + # 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='') authd_client.sleep() @@ -45,4 +46,3 @@ Indices and tables * :ref:`genindex` * :ref:`modindex` * :ref:`search` - From c02574db30b8eea7bb03ff44d96d9bcde5269585 Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Mon, 23 Feb 2015 21:50:14 -0800 Subject: [PATCH 18/87] make the API code a bit DRYer --- fitbit/api.py | 234 +++++++++++++++++--------------------------------- 1 file changed, 80 insertions(+), 154 deletions(-) diff --git a/fitbit/api.py b/fitbit/api.py index 17b4954..f30f62a 100644 --- a/fitbit/api.py +++ b/fitbit/api.py @@ -246,6 +246,13 @@ def user_profile_update(self, data): self.API_VERSION) return self.make_request(url, data) + def _get_common_args(self, user_id=None): + common_args = (self.API_ENDPOINT, self.API_VERSION,) + if not user_id: + user_id = '-' + common_args += (user_id,) + return common_args + def _COLLECTION_RESOURCE(self, resource, date=None, user_id=None, data=None): """ @@ -272,26 +279,20 @@ def _COLLECTION_RESOURCE(self, resource, date=None, user_id=None, if not date: date = datetime.date.today() - if not user_id: - user_id = '-' if not isinstance(date, str): date = date.strftime('%Y-%m-%d') if not data: - url = "%s/%s/user/%s/%s/date/%s.json" % ( - self.API_ENDPOINT, - self.API_VERSION, - user_id, - resource, - date, + url = "{0}/{1}/user/{2}/{resource}/date/{date}.json".format( + *self._get_common_args(user_id), + resource=resource, + date=date ) else: data['date'] = date - url = "%s/%s/user/%s/%s.json" % ( - self.API_ENDPOINT, - self.API_VERSION, - user_id, - resource, + url = "{0}/{1}/user/{2}/{resource}.json".format( + *self._get_common_args(user_id), + resource=resource ) return self.make_request(url, data) @@ -314,11 +315,10 @@ def _DELETE_COLLECTION_RESOURCE(self, resource, log_id): delete_bp(log_id) """ - url = "%s/%s/user/-/%s/%s.json" % ( - self.API_ENDPOINT, - self.API_VERSION, - resource, - log_id, + url = "{0}/{1}/user/-/{resource}/{log_id}.json".format( + *self._get_common_args(), + resource=resource, + log_id=log_id ) response = self.make_request(url, method='DELETE') return response @@ -335,9 +335,6 @@ def time_series(self, resource, user_id=None, base_date='today', https://wiki.fitbit.com/display/API/API-Get-Time-Series """ - if not user_id: - user_id = '-' - if period and end_date: raise TypeError("Either end_date or period can be specified, not both") @@ -354,13 +351,11 @@ def time_series(self, resource, user_id=None, base_date='today', if not isinstance(base_date, str): base_date = base_date.strftime('%Y-%m-%d') - url = "%s/%s/user/%s/%s/date/%s/%s.json" % ( - self.API_ENDPOINT, - self.API_VERSION, - user_id, - resource, - base_date, - end + url = "{0}/{1}/user/{2}/{resource}/date/{base_date}/{end}.json".format( + *self._get_common_args(user_id), + resource=resource, + base_date=base_date, + end=end ) return self.make_request(url) @@ -386,12 +381,11 @@ def intraday_time_series(self, resource, base_date='today', detail_level='1min', if not detail_level in ['1min', '15min']: raise ValueError("Period must be either '1min' or '15min'") - url = "%s/%s/user/-/%s/date/%s/1d/%s" % ( - self.API_ENDPOINT, - self.API_VERSION, - resource, - base_date, - detail_level + url = "{0}/{1}/user/-/{resource}/date/{base_date}/1d/{detail_level}".format( + *self._get_common_args(), + resource=resource, + base_date=base_date, + detail_level=detail_level ) if start_time: @@ -423,9 +417,6 @@ def activity_stats(self, user_id=None, qualifier=''): favorite_activities(user_id=None, qualifier='') frequent_activities(user_id=None, qualifier='') """ - if not user_id: - user_id = '-' - if qualifier: if qualifier in self._qualifiers: qualifier = '/%s' % qualifier @@ -435,11 +426,9 @@ def activity_stats(self, user_id=None, qualifier=''): else: qualifier = '' - url = "%s/%s/user/%s/activities%s.json" % ( - self.API_ENDPOINT, - self.API_VERSION, - user_id, - qualifier, + url = "{0}/{1}/user/{2}/activities{qualifier}.json".format( + *self._get_common_args(user_id), + qualifier=qualifier ) return self.make_request(url) @@ -455,14 +444,9 @@ def _food_stats(self, user_id=None, qualifier=''): * https://wiki.fitbit.com/display/API/API-Get-Frequent-Foods * https://wiki.fitbit.com/display/API/API-Get-Favorite-Foods """ - if not user_id: - user_id = '-' - - url = "%s/%s/user/%s/foods/log/%s.json" % ( - self.API_ENDPOINT, - self.API_VERSION, - user_id, - qualifier, + url = "{0}/{1}/user/{2}/foods/log/{qualifier}.json".format( + *self._get_common_args(user_id), + qualifier=qualifier ) return self.make_request(url) @@ -470,10 +454,9 @@ def add_favorite_activity(self, activity_id): """ https://wiki.fitbit.com/display/API/API-Add-Favorite-Activity """ - url = "%s/%s/user/-/activities/favorite/%s.json" % ( - self.API_ENDPOINT, - self.API_VERSION, - activity_id, + url = "{0}/{1}/user/-/activities/favorite/{activity_id}.json".format( + *self._get_common_args(), + activity_id=activity_id ) return self.make_request(url, method='POST') @@ -481,19 +464,16 @@ def log_activity(self, data): """ https://wiki.fitbit.com/display/API/API-Log-Activity """ - url = "%s/%s/user/-/activities.json" % ( - self.API_ENDPOINT, - self.API_VERSION) + url = "{0}/{1}/user/-/activities.json".format(*self._get_common_args()) return self.make_request(url, data = data) def delete_favorite_activity(self, activity_id): """ https://wiki.fitbit.com/display/API/API-Delete-Favorite-Activity """ - url = "%s/%s/user/-/activities/favorite/%s.json" % ( - self.API_ENDPOINT, - self.API_VERSION, - activity_id, + url = "{0}/{1}/user/-/activities/favorite/{activity_id}.json".format( + *self._get_common_args(), + activity_id=activity_id ) return self.make_request(url, method='DELETE') @@ -501,10 +481,9 @@ def add_favorite_food(self, food_id): """ https://wiki.fitbit.com/display/API/API-Add-Favorite-Food """ - url = "%s/%s/user/-/foods/log/favorite/%s.json" % ( - self.API_ENDPOINT, - self.API_VERSION, - food_id, + url = "{0}/{1}/user/-/foods/log/favorite/{food_id}.json".format( + *self._get_common_args(), + food_id=food_id ) return self.make_request(url, method='POST') @@ -512,10 +491,9 @@ def delete_favorite_food(self, food_id): """ https://wiki.fitbit.com/display/API/API-Delete-Favorite-Food """ - url = "%s/%s/user/-/foods/log/favorite/%s.json" % ( - self.API_ENDPOINT, - self.API_VERSION, - food_id, + url = "{0}/{1}/user/-/foods/log/favorite/{food_id}.json".format( + *self._get_common_args(), + food_id=food_id ) return self.make_request(url, method='DELETE') @@ -523,40 +501,30 @@ def create_food(self, data): """ https://wiki.fitbit.com/display/API/API-Create-Food """ - url = "%s/%s/user/-/foods.json" % ( - self.API_ENDPOINT, - self.API_VERSION, - ) + 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 """ - url = "%s/%s/user/-/meals.json" % ( - self.API_ENDPOINT, - self.API_VERSION, - ) + 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 """ - url = "%s/%s/user/-/devices.json" % ( - self.API_ENDPOINT, - self.API_VERSION, - ) + 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 """ - url = "%s/%s/user/-/devices/tracker/%s/alarms.json" % ( - self.API_ENDPOINT, - self.API_VERSION, - device_id + url = "{0}/{1}/user/-/devices/tracker/{device_id}/alarms.json".format( + *self._get_common_args(), + device_id=device_id ) return self.make_request(url) @@ -566,10 +534,9 @@ def add_alarm(self, device_id, alarm_time, week_days, recurring=False, enabled=T https://wiki.fitbit.com/display/API/API-Devices-Add-Alarm alarm_time should be a timezone aware datetime object. """ - url = "%s/%s/user/-/devices/tracker/%s/alarms.json" % ( - self.API_ENDPOINT, - self.API_VERSION, - device_id + url = "{0}/{1}/user/-/devices/tracker/{device_id}/alarms.json".format( + *self._get_common_args(), + device_id=device_id ) alarm_time = alarm_time.strftime("%H:%M%z") # Check week_days list @@ -607,11 +574,10 @@ def update_alarm(self, device_id, alarm_id, alarm_time, week_days, recurring=Fal for day in week_days: if day not in self.WEEK_DAYS: raise ValueError("Incorrect week day %s. see WEEK_DAY_LIST." % day) - url = "%s/%s/user/-/devices/tracker/%s/alarms/%s.json" % ( - self.API_ENDPOINT, - self.API_VERSION, - device_id, - alarm_id + url = "{0}/{1}/user/-/devices/tracker/{device_id}/alarms/{alarm_id}.json".format( + *self._get_common_args(), + device_id=device_id, + alarm_id=alarm_id ) alarm_time = alarm_time.strftime("%H:%M%z") @@ -635,11 +601,10 @@ def delete_alarm(self, device_id, alarm_id): """ https://wiki.fitbit.com/display/API/API-Devices-Delete-Alarm """ - url = "%s/%s/user/-/devices/tracker/%s/alarms/%s.json" % ( - self.API_ENDPOINT, - self.API_VERSION, - device_id, - alarm_id + url = "{0}/{1}/user/-/devices/tracker/{device_id}/alarms/{alarm_id}.json".format( + *self._get_common_args(), + device_id=device_id, + alarm_id=alarm_id ) return self.make_request(url, method="DELETE") @@ -648,12 +613,11 @@ def get_sleep(self, date): https://wiki.fitbit.com/display/API/API-Get-Sleep date should be a datetime.date object. """ - url = "%s/%s/user/-/sleep/date/%s-%s-%s.json" % ( - self.API_ENDPOINT, - self.API_VERSION, - date.year, - date.month, - date.day + url = "{0}/{1}/user/-/sleep/date/{year}-{month}-{day}.json".format( + *self._get_common_args(), + year=date.year, + month=date.month, + day=date.day ) return self.make_request(url) @@ -735,52 +699,7 @@ def get_bodyweight(self, base_date=None, user_id=None, period=None, end_date=Non You can specify period or end_date, or neither, but not both. """ - if not base_date: - base_date = datetime.date.today() - - if not user_id: - user_id = '-' - - if period and end_date: - raise TypeError("Either end_date or period can be specified, not both") - - if not isinstance(base_date, str): - base_date_string = base_date.strftime('%Y-%m-%d') - else: - base_date_string = base_date - - if period: - if not period in ['1d', '7d', '30d', '1w', '1m', '3m', '6m', '1y', 'max']: - raise ValueError("Period must be one of '1d', '7d', '30d', '1w', '1m', '3m', '6m', '1y', 'max'") - - url = "%s/%s/user/%s/body/log/weight/date/%s/%s.json" % ( - self.API_ENDPOINT, - self.API_VERSION, - user_id, - base_date_string, - period - ) - elif end_date: - if not isinstance(end_date, str): - end_string = end_date.strftime('%Y-%m-%d') - else: - end_string = end_date - - url = "%s/%s/user/%s/body/log/weight/date/%s/%s.json" % ( - self.API_ENDPOINT, - self.API_VERSION, - user_id, - base_date_string, - end_string - ) - else: - url = "%s/%s/user/%s/body/log/weight/date/%s.json" % ( - self.API_ENDPOINT, - self.API_VERSION, - user_id, - base_date_string, - ) - return self.make_request(url) + return self._get_body('weight', base_date, user_id, period, end_date) def get_bodyfat(self, base_date=None, user_id=None, period=None, end_date=None): """ @@ -791,6 +710,10 @@ def get_bodyfat(self, base_date=None, user_id=None, period=None, end_date=None): You can specify period or end_date, or neither, but not both. """ + return self._get_body('fat', base_date, user_id, period, end_date) + + def _get_body(self, _type, base_date=None, user_id=None, period=None, + end_date=None): if not base_date: base_date = datetime.date.today() @@ -809,10 +732,11 @@ def get_bodyfat(self, base_date=None, user_id=None, period=None, end_date=None): if not period in ['1d', '7d', '30d', '1w', '1m', '3m', '6m', '1y', 'max']: raise ValueError("Period must be one of '1d', '7d', '30d', '1w', '1m', '3m', '6m', '1y', 'max'") - url = "%s/%s/user/%s/body/log/fat/date/%s/%s.json" % ( + url = "%s/%s/user/%s/body/log/%s/date/%s/%s.json" % ( self.API_ENDPOINT, self.API_VERSION, user_id, + _type, base_date_string, period ) @@ -822,18 +746,20 @@ def get_bodyfat(self, base_date=None, user_id=None, period=None, end_date=None): else: end_string = end_date - url = "%s/%s/user/%s/body/log/fat/date/%s/%s.json" % ( + url = "%s/%s/user/%s/body/log/%s/date/%s/%s.json" % ( self.API_ENDPOINT, self.API_VERSION, user_id, + _type, base_date_string, end_string ) else: - url = "%s/%s/user/%s/body/log/fat/date/%s.json" % ( + url = "%s/%s/user/%s/body/log/%s/date/%s.json" % ( self.API_ENDPOINT, self.API_VERSION, user_id, + _type, base_date_string, ) return self.make_request(url) From f621e94095ff5aca795cab61b2dc6538f0fcee13 Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Tue, 24 Feb 2015 21:20:36 -0800 Subject: [PATCH 19/87] DRY things off a bit more --- fitbit/api.py | 209 ++++++++++++++++---------------------------------- 1 file changed, 66 insertions(+), 143 deletions(-) diff --git a/fitbit/api.py b/fitbit/api.py index f30f62a..df79523 100644 --- a/fitbit/api.py +++ b/fitbit/api.py @@ -170,6 +170,7 @@ class Fitbit(object): def __init__(self, client_key, client_secret, system=US, **kwargs): self.client = FitbitOauthClient(client_key, client_secret, **kwargs) self.SYSTEM = system + self.periods = ['1d', '7d', '30d', '1w', '1m', '3m', '6m', '1y', 'max'] # All of these use the same patterns, define the method for accessing # creating and deleting records once, and use curry to make individual @@ -225,10 +226,7 @@ def user_profile_get(self, user_id=None): https://wiki.fitbit.com/display/API/API-Get-User-Info """ - if user_id is None: - user_id = "-" - url = "%s/%s/user/%s/profile.json" % (self.API_ENDPOINT, - self.API_VERSION, user_id) + url = "{0}/{1}/user/{2}/profile.json".format(*self._get_common_args(user_id)) return self.make_request(url) def user_profile_update(self, data): @@ -242,8 +240,7 @@ def user_profile_update(self, data): https://wiki.fitbit.com/display/API/API-Update-User-Info """ - url = "%s/%s/user/-/profile.json" % (self.API_ENDPOINT, - self.API_VERSION) + url = "{0}/{1}/user/-/profile.json".format(*self._get_common_args()) return self.make_request(url, data) def _get_common_args(self, user_id=None): @@ -253,6 +250,11 @@ def _get_common_args(self, user_id=None): common_args += (user_id,) return common_args + def _get_date_string(self, date): + if not isinstance(date, str): + return date.strftime('%Y-%m-%d') + return date + def _COLLECTION_RESOURCE(self, resource, date=None, user_id=None, data=None): """ @@ -279,21 +281,15 @@ def _COLLECTION_RESOURCE(self, resource, date=None, user_id=None, if not date: date = datetime.date.today() - if not isinstance(date, str): - date = date.strftime('%Y-%m-%d') + date_string = self._get_date_string(date) + kwargs = {'resource': resource, 'date': date_string} if not data: - url = "{0}/{1}/user/{2}/{resource}/date/{date}.json".format( - *self._get_common_args(user_id), - resource=resource, - date=date - ) + base_url = "{0}/{1}/user/{2}/{resource}/date/{date}.json" else: - data['date'] = date - url = "{0}/{1}/user/{2}/{resource}.json".format( - *self._get_common_args(user_id), - resource=resource - ) + data['date'] = date_string + base_url = "{0}/{1}/user/{2}/{resource}.json" + url = base_url.format(*self._get_common_args(user_id), **kwargs) return self.make_request(url, data) def _DELETE_COLLECTION_RESOURCE(self, resource, log_id): @@ -339,22 +335,17 @@ def time_series(self, resource, user_id=None, base_date='today', raise TypeError("Either end_date or period can be specified, not both") if end_date: - if not isinstance(end_date, str): - end = end_date.strftime('%Y-%m-%d') - else: - end = end_date + end = self._get_date_string(end_date) else: - if not period in ['1d', '7d', '30d', '1w', '1m', '3m', '6m', '1y', 'max']: - raise ValueError("Period must be one of '1d', '7d', '30d', '1w', '1m', '3m', '6m', '1y', 'max'") + if not period in self.periods: + raise ValueError("Period must be one of %s" + % ','.join(self.periods)) end = period - if not isinstance(base_date, str): - base_date = base_date.strftime('%Y-%m-%d') - url = "{0}/{1}/user/{2}/{resource}/date/{base_date}/{end}.json".format( *self._get_common_args(user_id), resource=resource, - base_date=base_date, + base_date=self._get_date_string(base_date), end=end ) return self.make_request(url) @@ -375,16 +366,13 @@ def intraday_time_series(self, resource, base_date='today', detail_level='1min', if end_time and not start_time: raise TypeError("You must provide a start time when you provide an end time") - if not isinstance(base_date, str): - base_date = base_date.strftime('%Y-%m-%d') - if not detail_level in ['1min', '15min']: raise ValueError("Period must be either '1min' or '15min'") url = "{0}/{1}/user/-/{resource}/date/{base_date}/1d/{detail_level}".format( *self._get_common_args(), resource=resource, - base_date=base_date, + base_date=self._get_date_string(base_date), detail_level=detail_level ) @@ -422,7 +410,7 @@ def activity_stats(self, user_id=None, qualifier=''): qualifier = '/%s' % qualifier else: raise ValueError("Qualifier must be one of %s" - % ', '.join(self._qualifiers)) + % ', '.join(self._qualifiers)) else: qualifier = '' @@ -631,30 +619,23 @@ def log_sleep(self, start_time, duration): 'duration': duration, 'date': start_time.strftime("%Y-%m-%d"), } - url = "%s/%s/user/-/sleep.json" % ( - self.API_ENDPOINT, - self.API_VERSION, - ) + url = "{0}/{1}/user/-/sleep.json".format(*self._get_common_args()) return self.make_request(url, data=data, method="POST") def activities_list(self): """ https://wiki.fitbit.com/display/API/API-Browse-Activities """ - url = "%s/%s/activities.json" % ( - self.API_ENDPOINT, - self.API_VERSION, - ) + 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 """ - url = "%s/%s/activities/%s.json" % ( - self.API_ENDPOINT, - self.API_VERSION, - activity_id + url = "{0}/{1}/activities/{activity_id}.json".format( + *self._get_common_args(), + activity_id=activity_id ) return self.make_request(url) @@ -662,10 +643,9 @@ def search_foods(self, query): """ https://wiki.fitbit.com/display/API/API-Search-Foods """ - url = "%s/%s/foods/search.json?%s" % ( - self.API_ENDPOINT, - self.API_VERSION, - urlencode({'query': query}) + url = "{0}/{1}/foods/search.json?{encoded_query}".format( + *self._get_common_args(), + encoded_query=urlencode({'query': query}) ) return self.make_request(url) @@ -673,10 +653,9 @@ def food_detail(self, food_id): """ https://wiki.fitbit.com/display/API/API-Get-Food """ - url = "%s/%s/foods/%s.json" % ( - self.API_ENDPOINT, - self.API_VERSION, - food_id + url = "{0}/{1}/foods/{food_id}.json".format( + *self._get_common_args(), + food_id=food_id ) return self.make_request(url) @@ -684,10 +663,7 @@ def food_units(self): """ https://wiki.fitbit.com/display/API/API-Get-Food-Units """ - url = "%s/%s/foods/units.json" % ( - self.API_ENDPOINT, - self.API_VERSION - ) + 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): @@ -712,69 +688,37 @@ def get_bodyfat(self, base_date=None, user_id=None, period=None, end_date=None): """ return self._get_body('fat', base_date, user_id, period, end_date) - def _get_body(self, _type, base_date=None, user_id=None, period=None, + def _get_body(self, type_, base_date=None, user_id=None, period=None, end_date=None): if not base_date: base_date = datetime.date.today() - if not user_id: - user_id = '-' - if period and end_date: raise TypeError("Either end_date or period can be specified, not both") - if not isinstance(base_date, str): - base_date_string = base_date.strftime('%Y-%m-%d') - else: - base_date_string = base_date + base_date_string = self._get_date_string(base_date) + kwargs = {'type_': type_} + base_url = "{0}/{1}/user/{2}/body/log/{type_}/date/{date_string}.json" if period: - if not period in ['1d', '7d', '30d', '1w', '1m', '3m', '6m', '1y', 'max']: - raise ValueError("Period must be one of '1d', '7d', '30d', '1w', '1m', '3m', '6m', '1y', 'max'") - - url = "%s/%s/user/%s/body/log/%s/date/%s/%s.json" % ( - self.API_ENDPOINT, - self.API_VERSION, - user_id, - _type, - base_date_string, - period - ) + if not period in self.periods: + raise ValueError("Period must be one of %s" % + ','.join(self.periods)) + kwargs['date_string'] = '/'.join([base_date_string, period]) elif end_date: - if not isinstance(end_date, str): - end_string = end_date.strftime('%Y-%m-%d') - else: - end_string = end_date - - url = "%s/%s/user/%s/body/log/%s/date/%s/%s.json" % ( - self.API_ENDPOINT, - self.API_VERSION, - user_id, - _type, - base_date_string, - end_string - ) + end_string = self._get_date_string(end_date) + kwargs['date_string'] = '/'.join([base_date_string, end_string]) else: - url = "%s/%s/user/%s/body/log/%s/date/%s.json" % ( - self.API_ENDPOINT, - self.API_VERSION, - user_id, - _type, - base_date_string, - ) + kwargs['date_string'] = base_date_string + + url = base_url.format(*self._get_common_args(user_id), **kwargs) return self.make_request(url) def get_friends(self, user_id=None): """ https://wiki.fitbit.com/display/API/API-Get-Friends """ - if not user_id: - user_id = '-' - url = "%s/%s/user/%s/friends.json" % ( - self.API_ENDPOINT, - self.API_VERSION, - user_id - ) + 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): @@ -783,10 +727,9 @@ def get_friends_leaderboard(self, period): """ if not period in ['7d', '30d']: raise ValueError("Period must be one of '7d', '30d'") - url = "%s/%s/user/-/friends/leaders/%s.json" % ( - self.API_ENDPOINT, - self.API_VERSION, - period + url = "{0}/{1}/user/-/friends/leaders/{period}.json".format( + *self._get_common_args(), + period=period ) return self.make_request(url) @@ -794,10 +737,7 @@ def invite_friend(self, data): """ https://wiki.fitbit.com/display/API/API-Create-Invite """ - url = "%s/%s/user/-/friends/invitations.json" % ( - self.API_ENDPOINT, - self.API_VERSION, - ) + url = "{0}/{1}/user/-/friends/invitations.json".format(*self._get_common_args()) return self.make_request(url, data=data) def invite_friend_by_email(self, email): @@ -818,10 +758,9 @@ def respond_to_invite(self, other_user_id, accept=True): """ https://wiki.fitbit.com/display/API/API-Accept-Invite """ - url = "%s/%s/user/-/friends/invitations/%s.json" % ( - self.API_ENDPOINT, - self.API_VERSION, - other_user_id, + url = "{0}/{1}/user/-/friends/invitations/{user_id}.json".format( + *self._get_common_args(), + user_id=other_user_id ) accept = 'true' if accept else 'false' return self.make_request(url, data={'accept': accept}) @@ -842,13 +781,7 @@ def get_badges(self, user_id=None): """ https://wiki.fitbit.com/display/API/API-Get-Badges """ - if not user_id: - user_id = '-' - url = "%s/%s/user/%s/badges.json" % ( - self.API_ENDPOINT, - self.API_VERSION, - user_id - ) + url = "{0}/{1}/user/{2}/badges.json".format(*self._get_common_args(user_id)) return self.make_request(url) def subscription(self, subscription_id, subscriber_id, collection=None, @@ -856,22 +789,15 @@ def subscription(self, subscription_id, subscriber_id, collection=None, """ https://wiki.fitbit.com/display/API/Fitbit+Subscriptions+API """ - if not collection: - url = "%s/%s/user/-/apiSubscriptions/%s.json" % ( - self.API_ENDPOINT, - self.API_VERSION, - subscription_id - ) - else: - url = "%s/%s/user/-/%s/apiSubscriptions/%s-%s.json" % ( - self.API_ENDPOINT, - self.API_VERSION, - collection, - subscription_id, - collection - ) + base_url = "{0}/{1}/user/-{collection}/apiSubscriptions/{end_string}.json" + kwargs = {'collection': '', 'end_string': subscription_id} + if collection: + kwargs = { + 'end_string': '-'.join([subscription_id, collection]), + 'collection': '/' + collection + } return self.make_request( - url, + base_url.format(*self._get_common_args(), **kwargs), method=method, headers={"X-Fitbit-Subscriber-id": subscriber_id} ) @@ -880,12 +806,9 @@ def list_subscriptions(self, collection=''): """ https://wiki.fitbit.com/display/API/Fitbit+Subscriptions+API """ - if collection: - collection = '/%s' % collection - url = "%s/%s/user/-%s/apiSubscriptions.json" % ( - self.API_ENDPOINT, - self.API_VERSION, - collection, + url = "{0}/{1}/user/-{collection}/apiSubscriptions.json".format( + *self._get_common_args(), + collection='/{0}'.format(collection) if collection else '' ) return self.make_request(url) From 09da52e4c51af3d16718b6bef850b98c3f9e53ab Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Tue, 24 Feb 2015 21:20:56 -0800 Subject: [PATCH 20/87] add favorite activity and sleep tests --- fitbit_tests/test_api.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/fitbit_tests/test_api.py b/fitbit_tests/test_api.py index 11837e6..52e817a 100644 --- a/fitbit_tests/test_api.py +++ b/fitbit_tests/test_api.py @@ -327,6 +327,10 @@ def test_intraday_timeseries(fb, resource, base_date, detail_level, start_time, detail_level='1min', 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") + def test_sleep(self): + today = datetime.date.today().strftime('%Y-%m-%d') + self.common_api_test('sleep', (today,), {}, ("%s/-/sleep/date/%s.json" % (URLBASE, today), None), {}) + self.common_api_test('sleep', (today, "USER_ID"), {}, ("%s/USER_ID/sleep/date/%s.json" % (URLBASE, today), None), {}) def test_foods(self): today = datetime.date.today().strftime('%Y-%m-%d') @@ -370,6 +374,10 @@ def test_activities(self): url = "%s/%s/activities/FOOBAR.json" % (Fitbit.API_ENDPOINT, Fitbit.API_VERSION) self.common_api_test('activity_detail', ("FOOBAR",), {}, (url,), {}) + url = URLBASE + "/-/activities/favorite/activity_id.json" + self.common_api_test('add_favorite_activity', ('activity_id',), {}, (url,), {'method': 'POST'}) + self.common_api_test('delete_favorite_activity', ('activity_id',), {}, (url,), {'method': 'DELETE'}) + def test_bodyweight(self): def test_get_bodyweight(fb, base_date=None, user_id=None, period=None, end_date=None, expected_url=None): with mock.patch.object(fb, 'make_request') as make_request: From 15fe249512be6db284809ba2061c08688ba51c31 Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Wed, 25 Feb 2015 20:00:47 -0800 Subject: [PATCH 21/87] normalize fitbit attributes --- fitbit/api.py | 26 +++++++++++++------------- fitbit_tests/test_api.py | 15 +++++++++------ 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/fitbit/api.py b/fitbit/api.py index df79523..0a4fc0f 100644 --- a/fitbit/api.py +++ b/fitbit/api.py @@ -149,8 +149,9 @@ class Fitbit(object): API_ENDPOINT = "https://api.fitbit.com" API_VERSION = 1 WEEK_DAYS = ['SUNDAY', 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY'] + PERIODS = ['1d', '7d', '30d', '1w', '1m', '3m', '6m', '1y', 'max'] - _resource_list = [ + RESOURCE_LIST = [ 'body', 'activities', 'foods/log', @@ -161,7 +162,7 @@ class Fitbit(object): 'glucose', ] - _qualifiers = [ + QUALIFIERS = [ 'recent', 'favorite', 'frequent', @@ -169,13 +170,12 @@ class Fitbit(object): def __init__(self, client_key, client_secret, system=US, **kwargs): self.client = FitbitOauthClient(client_key, client_secret, **kwargs) - self.SYSTEM = system - self.periods = ['1d', '7d', '30d', '1w', '1m', '3m', '6m', '1y', 'max'] + self.system = system # All of these use the same patterns, define the method for accessing # creating and deleting records once, and use curry to make individual # Methods for each - for resource in self._resource_list: + for resource in Fitbit.RESOURCE_LIST: underscore_resource = resource.replace('/', '_') setattr(self, underscore_resource, curry(self._COLLECTION_RESOURCE, resource)) @@ -185,7 +185,7 @@ def __init__(self, client_key, client_secret, system=US, **kwargs): setattr(self, 'delete_%s' % underscore_resource, curry( self._DELETE_COLLECTION_RESOURCE, resource)) - for qualifier in self._qualifiers: + for qualifier in Fitbit.QUALIFIERS: setattr(self, '%s_activities' % qualifier, curry(self.activity_stats, qualifier=qualifier)) setattr(self, '%s_foods' % qualifier, curry(self._food_stats, qualifier=qualifier)) @@ -194,7 +194,7 @@ def make_request(self, *args, **kwargs): ##@ This should handle data level errors, improper requests, and bad # serialization headers = kwargs.get('headers', {}) - headers.update({'Accept-Language': self.SYSTEM}) + headers.update({'Accept-Language': self.system}) kwargs['headers'] = headers method = kwargs.get('method', 'POST' if 'data' in kwargs else 'GET') @@ -337,9 +337,9 @@ def time_series(self, resource, user_id=None, base_date='today', if end_date: end = self._get_date_string(end_date) else: - if not period in self.periods: + if not period in Fitbit.PERIODS: raise ValueError("Period must be one of %s" - % ','.join(self.periods)) + % ','.join(Fitbit.PERIODS)) end = period url = "{0}/{1}/user/{2}/{resource}/date/{base_date}/{end}.json".format( @@ -406,11 +406,11 @@ def activity_stats(self, user_id=None, qualifier=''): frequent_activities(user_id=None, qualifier='') """ if qualifier: - if qualifier in self._qualifiers: + if qualifier in Fitbit.QUALIFIERS: qualifier = '/%s' % qualifier else: raise ValueError("Qualifier must be one of %s" - % ', '.join(self._qualifiers)) + % ', '.join(Fitbit.QUALIFIERS)) else: qualifier = '' @@ -701,9 +701,9 @@ def _get_body(self, type_, base_date=None, user_id=None, period=None, kwargs = {'type_': type_} base_url = "{0}/{1}/user/{2}/body/log/{type_}/date/{date_string}.json" if period: - if not period in self.periods: + if not period in Fitbit.PERIODS: raise ValueError("Period must be one of %s" % - ','.join(self.periods)) + ','.join(Fitbit.PERIODS)) kwargs['date_string'] = '/'.join([base_date_string, period]) elif end_date: end_string = self._get_date_string(end_date) diff --git a/fitbit_tests/test_api.py b/fitbit_tests/test_api.py index 52e817a..4f7ceea 100644 --- a/fitbit_tests/test_api.py +++ b/fitbit_tests/test_api.py @@ -24,13 +24,16 @@ def verify_raises(self, funcname, args, kwargs, exc): self.assertRaises(exc, getattr(self.fb, funcname), *args, **kwargs) class APITest(TestBase): - """Tests for python-fitbit API, not directly involved in getting authenticated""" + """ + Tests for python-fitbit API, not directly involved in getting + authenticated + """ 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" @@ -50,7 +53,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) @@ -63,7 +66,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) @@ -76,7 +79,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) @@ -92,7 +95,7 @@ def test_user_profile_update(self): self.common_api_test('user_profile_update', (data,), {}, (url, data), {}) class CollectionResourceTest(TestBase): - """Tests for _COLLECTION_RESOURCE""" + """ Tests for _COLLECTION_RESOURCE """ def test_all_args(self): # If we pass all the optional args, the right things happen resource = "RESOURCE" From 150a44612a9feeb705cdf713701bbf478346d6e3 Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Thu, 26 Feb 2015 21:36:25 -0800 Subject: [PATCH 22/87] reorganize the test suite a bit --- fitbit_tests/__init__.py | 13 +- fitbit_tests/test_api.py | 323 ++++++++++++++++++++++++++------------- 2 files changed, 227 insertions(+), 109 deletions(-) diff --git a/fitbit_tests/__init__.py b/fitbit_tests/__init__.py index e3e0700..ae6932b 100644 --- a/fitbit_tests/__init__.py +++ b/fitbit_tests/__init__.py @@ -1,7 +1,14 @@ import unittest from .test_exceptions import ExceptionTest from .test_auth import AuthTest -from .test_api import APITest, CollectionResourceTest, DeleteCollectionResourceTest, MiscTest +from .test_api import ( + APITest, + CollectionResourceTest, + DeleteCollectionResourceTest, + ResourceAccessTest, + SubscriptionsTest, + PartnerAPITest +) def all_tests(consumer_key="", consumer_secret="", user_key=None, user_secret=None): @@ -17,5 +24,7 @@ def all_tests(consumer_key="", consumer_secret="", user_key=None, user_secret=No suite.addTest(unittest.makeSuite(APITest)) suite.addTest(unittest.makeSuite(CollectionResourceTest)) suite.addTest(unittest.makeSuite(DeleteCollectionResourceTest)) - suite.addTest(unittest.makeSuite(MiscTest)) + suite.addTest(unittest.makeSuite(ResourceAccessTest)) + suite.addTest(unittest.makeSuite(SubscriptionsTest)) + suite.addTest(unittest.makeSuite(PartnerAPITest)) return suite diff --git a/fitbit_tests/test_api.py b/fitbit_tests/test_api.py index 4f7ceea..1c8c822 100644 --- a/fitbit_tests/test_api.py +++ b/fitbit_tests/test_api.py @@ -23,6 +23,7 @@ def common_api_test(self, funcname, args, kwargs, expected_args, expected_kwargs def verify_raises(self, funcname, args, kwargs, exc): self.assertRaises(exc, getattr(self.fb, funcname), *args, **kwargs) + class APITest(TestBase): """ Tests for python-fitbit API, not directly involved in getting @@ -84,15 +85,6 @@ def test_make_request_delete_not_204(self): client_make_request.return_value = mock_response self.assertRaises(DeleteError, self.fb.make_request, *ARGS, **KWARGS) - def test_user_profile_get(self): - user_id = "FOO" - url = URLBASE + "/%s/profile.json" % user_id - self.common_api_test('user_profile_get', (user_id,), {}, (url,), {}) - - def test_user_profile_update(self): - data = "BAR" - url = URLBASE + "/-/profile.json" - self.common_api_test('user_profile_update', (data,), {}, (url, data), {}) class CollectionResourceTest(TestBase): """ Tests for _COLLECTION_RESOURCE """ @@ -165,6 +157,7 @@ def test_body(self): self.assertEqual({'date': 1, 'user_id': 2, 'data': 3}, kwargs) self.assertEqual(999, retval) + class DeleteCollectionResourceTest(TestBase): """Tests for _DELETE_COLLECTION_RESOURCE""" def test_impl(self): @@ -204,11 +197,38 @@ def test_delete_water(self): self.assertEqual({'log_id': log_id}, kwargs) self.assertEqual(999, retval) -class MiscTest(TestBase): - def test_activities(self): - user_id = "Qui-Gon Jinn" - self.common_api_test('activities', (), {}, (URLBASE + "/%s/activities/date/%s.json" % (user_id, datetime.date.today().strftime('%Y-%m-%d'),),), {}) - self.common_api_test('activities', (), {}, (URLBASE + "/-/activities/date/%s.json" % datetime.date.today().strftime('%Y-%m-%d'),), {}) + +class ResourceAccessTest(TestBase): + """ + Class for testing the Fitbit Resource Access API: + https://wiki.fitbit.com/display/API/Fitbit+Resource+Access+API + """ + def test_user_profile_get(self): + """ + Test getting a user profile. + https://wiki.fitbit.com/display/API/API-Get-User-Info + + Tests the following HTTP method/URLs: + GET https://api.fitbit.com/1/user/FOO/profile.json + GET https://api.fitbit.com/1/user/-/profile.json + """ + user_id = "FOO" + url = URLBASE + "/%s/profile.json" % user_id + self.common_api_test('user_profile_get', (user_id,), {}, (url,), {}) + url = URLBASE + "/-/profile.json" + self.common_api_test('user_profile_get', (), {}, (url,), {}) + + def test_user_profile_update(self): + """ + Test updating a user profile. + https://wiki.fitbit.com/display/API/API-Update-User-Info + + Tests the following HTTP method/URLs: + POST https://api.fitbit.com/1/user/-/profile.json + """ + data = "BAR" + url = URLBASE + "/-/profile.json" + self.common_api_test('user_profile_update', (data,), {}, (url, data), {}) def test_recent_activities(self): user_id = "LukeSkywalker" @@ -273,63 +293,6 @@ def test_timeseries(fb, resource, user_id, base_date, period, end_date, expected test_timeseries(self.fb, resource, user_id=user_id, base_date=datetime.date(1992,5,12), period=None, end_date=end_date, expected_url=URLBASE + "/BAR/FOO/date/1992-05-12/1998-12-31.json") - def test_intraday_timeseries(self): - resource = 'FOO' - base_date = '1918-05-11' - - # detail_level must be valid - self.assertRaises( - ValueError, - self.fb.intraday_time_series, - resource, - base_date, - detail_level="xyz", - start_time=None, - end_time=None) - - # provide end_time if start_time provided - self.assertRaises( - TypeError, - self.fb.intraday_time_series, - resource, - base_date, - detail_level="1min", - start_time='12:55', - end_time=None) - - # provide start_time if end_time provided - self.assertRaises( - TypeError, - self.fb.intraday_time_series, - resource, - base_date, - detail_level="1min", - start_time=None, - end_time='12:55') - - def test_intraday_timeseries(fb, resource, base_date, detail_level, start_time, end_time, expected_url): - with mock.patch.object(fb, 'make_request') as make_request: - retval = fb.intraday_time_series(resource, base_date, detail_level, start_time, end_time) - args, kwargs = make_request.call_args - self.assertEqual((expected_url,), args) - - # Default - test_intraday_timeseries(self.fb, resource, base_date=base_date, - detail_level='1min', start_time=None, end_time=None, - expected_url=URLBASE + "/-/FOO/date/1918-05-11/1d/1min.json") - # start_date can be a date object - test_intraday_timeseries(self.fb, resource, base_date=datetime.date(1918, 5, 11), - detail_level='1min', start_time=None, end_time=None, - expected_url=URLBASE + "/-/FOO/date/1918-05-11/1d/1min.json") - # start_time can be a datetime object - test_intraday_timeseries(self.fb, resource, base_date=base_date, - detail_level='1min', 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 - test_intraday_timeseries(self.fb, resource, base_date=base_date, - detail_level='1min', 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") - def test_sleep(self): today = datetime.date.today().strftime('%Y-%m-%d') self.common_api_test('sleep', (today,), {}, ("%s/-/sleep/date/%s.json" % (URLBASE, today), None), {}) @@ -370,6 +333,16 @@ def test_badges(self): self.common_api_test('get_badges', (), {}, (url,), {}) def test_activities(self): + """ + Test the getting/creating/deleting various activity related items. + Tests the following HTTP method/URLs: + + GET https://api.fitbit.com/1/activities.json + POST https://api.fitbit.com/1/user/-/activities.json + GET https://api.fitbit.com/1/activities/FOOBAR.json + POST https://api.fitbit.com/1/user/-/activities/favorite/activity_id.json + DELETE https://api.fitbit.com/1/user/-/activities/favorite/activity_id.json + """ url = "%s/%s/activities.json" % (Fitbit.API_ENDPOINT, Fitbit.API_VERSION) self.common_api_test('activities_list', (), {}, (url,), {}) url = "%s/%s/user/-/activities.json" % (Fitbit.API_ENDPOINT, Fitbit.API_VERSION) @@ -381,49 +354,89 @@ def test_activities(self): self.common_api_test('add_favorite_activity', ('activity_id',), {}, (url,), {'method': 'POST'}) self.common_api_test('delete_favorite_activity', ('activity_id',), {}, (url,), {'method': 'DELETE'}) - def test_bodyweight(self): - def test_get_bodyweight(fb, base_date=None, user_id=None, period=None, end_date=None, expected_url=None): - with mock.patch.object(fb, 'make_request') as make_request: - fb.get_bodyweight(base_date, user_id=user_id, period=period, end_date=end_date) - args, kwargs = make_request.call_args - self.assertEqual((expected_url,), args) + def _test_get_bodyweight(self, base_date=None, user_id=None, period=None, + end_date=None, expected_url=None): + """ Helper method for testing retrieving body weight measurements """ + with mock.patch.object(self.fb, 'make_request') as make_request: + self.fb.get_bodyweight(base_date, user_id=user_id, period=period, + end_date=end_date) + args, kwargs = make_request.call_args + self.assertEqual((expected_url,), args) + def test_bodyweight(self): + """ + Tests for retrieving body weight measurements. + https://wiki.fitbit.com/display/API/API-Get-Body-Weight + 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 + GET https://api.fitbit.com/1/user/BAR/body/log/weight/date/1992-05-12/1d.json + GET https://api.fitbit.com/1/user/-/body/log/weight/date/2015-02-26.json + """ user_id = 'BAR' # No end_date or period - test_get_bodyweight(self.fb, base_date=datetime.date(1992, 5, 12), user_id=None, period=None, end_date=None, + self._test_get_bodyweight( + base_date=datetime.date(1992, 5, 12), user_id=None, period=None, + end_date=None, expected_url=URLBASE + "/-/body/log/weight/date/1992-05-12.json") # With end_date - test_get_bodyweight(self.fb, base_date=datetime.date(1992, 5, 12), user_id=user_id, period=None, end_date=datetime.date(1998, 12, 31), + self._test_get_bodyweight( + base_date=datetime.date(1992, 5, 12), user_id=user_id, period=None, + end_date=datetime.date(1998, 12, 31), expected_url=URLBASE + "/BAR/body/log/weight/date/1992-05-12/1998-12-31.json") # With period - test_get_bodyweight(self.fb, base_date=datetime.date(1992, 5, 12), user_id=user_id, period="1d", end_date=None, + self._test_get_bodyweight( + base_date=datetime.date(1992, 5, 12), user_id=user_id, period="1d", + end_date=None, expected_url=URLBASE + "/BAR/body/log/weight/date/1992-05-12/1d.json") # Date defaults to today - test_get_bodyweight(self.fb, base_date=None, user_id=None, period=None, end_date=None, - expected_url=URLBASE + "/-/body/log/weight/date/%s.json" % datetime.date.today().strftime('%Y-%m-%d')) + today = datetime.date.today().strftime('%Y-%m-%d') + self._test_get_bodyweight( + base_date=None, user_id=None, period=None, end_date=None, + expected_url=URLBASE + "/-/body/log/weight/date/%s.json" % today) - def test_bodyfat(self): - def test_get_bodyfat(fb, base_date=None, user_id=None, period=None, end_date=None, expected_url=None): - with mock.patch.object(fb, 'make_request') as make_request: - fb.get_bodyfat(base_date, user_id=user_id, period=period, end_date=end_date) - args, kwargs = make_request.call_args - self.assertEqual((expected_url,), args) + def _test_get_bodyfat(self, base_date=None, user_id=None, period=None, + end_date=None, expected_url=None): + """ Helper method for testing getting bodyfat measurements """ + with mock.patch.object(self.fb, 'make_request') as make_request: + self.fb.get_bodyfat(base_date, user_id=user_id, period=period, + end_date=end_date) + args, kwargs = make_request.call_args + self.assertEqual((expected_url,), args) + def test_bodyfat(self): + """ + Tests for retrieving bodyfat measurements. + https://wiki.fitbit.com/display/API/API-Get-Body-Fat + 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 + GET https://api.fitbit.com/1/user/BAR/body/log/fat/date/1992-05-12/1d.json + GET https://api.fitbit.com/1/user/-/body/log/fat/date/2015-02-26.json + """ user_id = 'BAR' # No end_date or period - test_get_bodyfat(self.fb, base_date=datetime.date(1992, 5, 12), user_id=None, period=None, end_date=None, + self._test_get_bodyfat( + base_date=datetime.date(1992, 5, 12), user_id=None, period=None, + end_date=None, expected_url=URLBASE + "/-/body/log/fat/date/1992-05-12.json") # With end_date - test_get_bodyfat(self.fb, base_date=datetime.date(1992, 5, 12), user_id=user_id, period=None, end_date=datetime.date(1998, 12, 31), + self._test_get_bodyfat( + base_date=datetime.date(1992, 5, 12), user_id=user_id, period=None, + end_date=datetime.date(1998, 12, 31), expected_url=URLBASE + "/BAR/body/log/fat/date/1992-05-12/1998-12-31.json") # With period - test_get_bodyfat(self.fb, base_date=datetime.date(1992, 5, 12), user_id=user_id, period="1d", end_date=None, + self._test_get_bodyfat( + base_date=datetime.date(1992, 5, 12), user_id=user_id, period="1d", + end_date=None, expected_url=URLBASE + "/BAR/body/log/fat/date/1992-05-12/1d.json") # Date defaults to today - test_get_bodyfat(self.fb, base_date=None, user_id=None, period=None, end_date=None, - expected_url=URLBASE + "/-/body/log/fat/date/%s.json" % datetime.date.today().strftime('%Y-%m-%d')) + today = datetime.date.today().strftime('%Y-%m-%d') + self._test_get_bodyfat( + base_date=None, user_id=None, period=None, end_date=None, + expected_url=URLBASE + "/-/body/log/fat/date/%s.json" % today) def test_friends(self): url = URLBASE + "/-/friends.json" @@ -448,20 +461,6 @@ def test_invitations(self): self.common_api_test('accept_invite', ("FOO",), {}, (url,), {'data':{'accept': "true"}}) self.common_api_test('reject_invite', ("FOO", ), {}, (url,), {'data':{'accept': "false"}}) - def test_subscriptions(self): - url = URLBASE + "/-/apiSubscriptions.json" - self.common_api_test('list_subscriptions', (), {}, (url,), {}) - url = URLBASE + "/-/FOO/apiSubscriptions.json" - self.common_api_test('list_subscriptions', ("FOO",), {}, (url,), {}) - url = URLBASE + "/-/apiSubscriptions/SUBSCRIPTION_ID.json" - self.common_api_test('subscription', ("SUBSCRIPTION_ID", "SUBSCRIBER_ID"), {}, - (url,), {'method': 'POST', 'headers': {'X-Fitbit-Subscriber-id': "SUBSCRIBER_ID"}}) - self.common_api_test('subscription', ("SUBSCRIPTION_ID", "SUBSCRIBER_ID"), {'method': 'THROW'}, - (url,), {'method': 'THROW', 'headers': {'X-Fitbit-Subscriber-id': "SUBSCRIBER_ID"}}) - url = URLBASE + "/-/COLLECTION/apiSubscriptions/SUBSCRIPTION_ID-COLLECTION.json" - self.common_api_test('subscription', ("SUBSCRIPTION_ID", "SUBSCRIBER_ID"), {'method': 'THROW', 'collection': "COLLECTION"}, - (url,), {'method': 'THROW', 'headers': {'X-Fitbit-Subscriber-id': "SUBSCRIBER_ID"}}) - def test_alarms(self): url = "%s/-/devices/tracker/%s/alarms.json" % (URLBASE, 'FOO') self.common_api_test('get_alarms', (), {'device_id': 'FOO'}, (url,), {}) @@ -529,3 +528,113 @@ def test_alarms(self): }, 'method': 'POST'} ) + + +class SubscriptionsTest(TestBase): + """ + Class for testing the Fitbit Subscriptions API: + https://wiki.fitbit.com/display/API/Fitbit+Subscriptions+API + """ + + def test_subscriptions(self): + """ + Subscriptions tests. Tests the following methods/URLs: + GET https://api.fitbit.com/1/user/-/apiSubscriptions.json + GET https://api.fitbit.com/1/user/-/FOO/apiSubscriptions.json + POST https://api.fitbit.com/1/user/-/apiSubscriptions/SUBSCRIPTION_ID.json + POST https://api.fitbit.com/1/user/-/apiSubscriptions/SUBSCRIPTION_ID.json + POST https://api.fitbit.com/1/user/-/COLLECTION/apiSubscriptions/SUBSCRIPTION_ID-COLLECTION.json + """ + url = URLBASE + "/-/apiSubscriptions.json" + self.common_api_test('list_subscriptions', (), {}, (url,), {}) + url = URLBASE + "/-/FOO/apiSubscriptions.json" + self.common_api_test('list_subscriptions', ("FOO",), {}, (url,), {}) + url = URLBASE + "/-/apiSubscriptions/SUBSCRIPTION_ID.json" + self.common_api_test('subscription', ("SUBSCRIPTION_ID", "SUBSCRIBER_ID"), {}, + (url,), {'method': 'POST', 'headers': {'X-Fitbit-Subscriber-id': "SUBSCRIBER_ID"}}) + self.common_api_test('subscription', ("SUBSCRIPTION_ID", "SUBSCRIBER_ID"), {'method': 'THROW'}, + (url,), {'method': 'THROW', 'headers': {'X-Fitbit-Subscriber-id': "SUBSCRIBER_ID"}}) + url = URLBASE + "/-/COLLECTION/apiSubscriptions/SUBSCRIPTION_ID-COLLECTION.json" + self.common_api_test('subscription', ("SUBSCRIPTION_ID", "SUBSCRIBER_ID"), {'method': 'THROW', 'collection': "COLLECTION"}, + (url,), {'method': 'THROW', 'headers': {'X-Fitbit-Subscriber-id': "SUBSCRIBER_ID"}}) + + +class PartnerAPITest(TestBase): + """ + Class for testing the Fitbit Partner API: + https://wiki.fitbit.com/display/API/Fitbit+Partner+API + """ + + def _test_intraday_timeseries(self, resource, base_date, detail_level, + start_time, end_time, expected_url): + """ Helper method for intraday timeseries tests """ + with mock.patch.object(self.fb, 'make_request') as make_request: + retval = self.fb.intraday_time_series( + resource, base_date, detail_level, start_time, end_time) + args, kwargs = make_request.call_args + self.assertEqual((expected_url,), args) + + def test_intraday_timeseries(self): + """ + Intraday Time Series tests: + https://wiki.fitbit.com/display/API/API-Get-Intraday-Time-Series + + Tests the following methods/URLs: + GET https://api.fitbit.com/1/user/-/FOO/date/1918-05-11/1d/1min.json + GET https://api.fitbit.com/1/user/-/FOO/date/1918-05-11/1d/1min.json + GET https://api.fitbit.com/1/user/-/FOO/date/1918-05-11/1d/1min/time/03:56/15:07.json + GET https://api.fitbit.com/1/user/-/FOO/date/1918-05-11/1d/1min/time/3:56/15:07.json + """ + resource = 'FOO' + base_date = '1918-05-11' + + # detail_level must be valid + self.assertRaises( + ValueError, + self.fb.intraday_time_series, + resource, + base_date, + detail_level="xyz", + start_time=None, + end_time=None) + + # provide end_time if start_time provided + self.assertRaises( + TypeError, + self.fb.intraday_time_series, + resource, + base_date, + detail_level="1min", + start_time='12:55', + end_time=None) + + # provide start_time if end_time provided + self.assertRaises( + TypeError, + self.fb.intraday_time_series, + resource, + base_date, + detail_level="1min", + start_time=None, + end_time='12:55') + + # Default + self._test_intraday_timeseries( + resource, base_date=base_date, detail_level='1min', + start_time=None, end_time=None, + expected_url=URLBASE + "/-/FOO/date/1918-05-11/1d/1min.json") + # start_date can be a date object + self._test_intraday_timeseries( + resource, base_date=datetime.date(1918, 5, 11), + detail_level='1min', start_time=None, end_time=None, + expected_url=URLBASE + "/-/FOO/date/1918-05-11/1d/1min.json") + # 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', + 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), + expected_url=URLBASE + "/-/FOO/date/1918-05-11/1d/1min/time/3:56/15:07.json") From 81428b9c01f459f88d934a8aeecbd461dd28c472 Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Fri, 27 Feb 2015 22:58:39 -0800 Subject: [PATCH 23/87] intraday should allow midnight times, #56 --- fitbit/api.py | 30 +++++++++++++----------------- fitbit_tests/test_api.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 17 deletions(-) diff --git a/fitbit/api.py b/fitbit/api.py index 0a4fc0f..465bc7e 100644 --- a/fitbit/api.py +++ b/fitbit/api.py @@ -360,14 +360,14 @@ def intraday_time_series(self, resource, base_date='today', detail_level='1min', https://wiki.fitbit.com/display/API/API-Get-Intraday-Time-Series """ - if start_time and not end_time: - raise TypeError("You must provide an end time when you provide a start time") - - if end_time and not start_time: - raise TypeError("You must provide a start time when you provide an end time") + # Check that the time range is valid + time_test = lambda t: not (t is None or isinstance(t, str) and not t) + time_map = list(map(time_test, [start_time, end_time])) + 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'") + raise ValueError("Period must be either '1min' or '15min'") url = "{0}/{1}/user/-/{resource}/date/{base_date}/1d/{detail_level}".format( *self._get_common_args(), @@ -376,17 +376,13 @@ def intraday_time_series(self, resource, base_date='today', detail_level='1min', detail_level=detail_level ) - if start_time: - time_init = start_time - if not isinstance(time_init, str): - time_init = start_time.strftime('%H:%M') - url = url + ('/time/%s' % (time_init)) - - if end_time: - time_fin = end_time - if not isinstance(time_fin, str): - time_fin = time_fin.strftime('%H:%M') - url = url + ('/%s' % (time_fin)) + if all(time_map): + url = url + '/time' + for time in [start_time, end_time]: + time_str = time + if not isinstance(time_str, str): + time_str = time.strftime('%H:%M') + url = url + ('/%s' % (time_str)) url = url + '.json' diff --git a/fitbit_tests/test_api.py b/fitbit_tests/test_api.py index 1c8c822..e94797b 100644 --- a/fitbit_tests/test_api.py +++ b/fitbit_tests/test_api.py @@ -607,6 +607,14 @@ def test_intraday_timeseries(self): detail_level="1min", start_time='12:55', end_time=None) + self.assertRaises( + TypeError, + self.fb.intraday_time_series, + resource, + base_date, + detail_level="1min", + start_time='12:55', + end_time='') # provide start_time if end_time provided self.assertRaises( @@ -617,6 +625,14 @@ def test_intraday_timeseries(self): detail_level="1min", start_time=None, end_time='12:55') + self.assertRaises( + TypeError, + self.fb.intraday_time_series, + resource, + base_date, + detail_level="1min", + start_time='', + end_time='12:55') # Default self._test_intraday_timeseries( @@ -638,3 +654,18 @@ def test_intraday_timeseries(self): resource, base_date=base_date, detail_level='1min', 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( + resource, base_date=base_date, detail_level='1min', + start_time=datetime.time(0, 0), end_time=datetime.time(15, 7), + expected_url=URLBASE + "/-/FOO/date/1918-05-11/1d/1min/time/00:00/15:07.json") + # end_time can be a midnight datetime object + self._test_intraday_timeseries( + resource, base_date=base_date, detail_level='1min', + start_time=datetime.time(3, 56), end_time=datetime.time(0, 0), + expected_url=URLBASE + "/-/FOO/date/1918-05-11/1d/1min/time/03:56/00:00.json") + # start_time and end_time can be a midnight datetime object + self._test_intraday_timeseries( + resource, base_date=base_date, detail_level='1min', + start_time=datetime.time(0, 0), end_time=datetime.time(0, 0), + expected_url=URLBASE + "/-/FOO/date/1918-05-11/1d/1min/time/00:00/00:00.json") From d9d74b8b9ce5ac9d85010a86c3a6b06be115ba82 Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Fri, 27 Mar 2015 11:52:41 -0700 Subject: [PATCH 24/87] upgrade requirements --- docs/conf.py | 2 +- requirements/dev.txt | 2 +- requirements/test.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 3e05f59..e7ef8b4 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -95,7 +95,7 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'default' +html_theme = 'classic' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the diff --git a/requirements/dev.txt b/requirements/dev.txt index 4ec8b19..68ce924 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,4 +1,4 @@ -r base.txt -r test.txt -tox>=1.8,<1.9 +tox>=1.8,<1.10 diff --git a/requirements/test.txt b/requirements/test.txt index 90279ae..3c4f925 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -1,3 +1,3 @@ mock>=1.0,<1.1 coverage>=3.7,<3.8 -Sphinx>=1.2,<1.3 +Sphinx>=1.2,<1.4 From fe9c0799105ab67c07c0040ba59c6e40341d98e2 Mon Sep 17 00:00:00 2001 From: Randi Cabezas Date: Sun, 24 May 2015 23:01:09 -0400 Subject: [PATCH 25/87] starting oauth2 --- fitbit/api.py | 130 +++++++++++++++++++++++++++++++++++++++++- gather_keys_oauth2.py | 52 +++++++++++++++++ 2 files changed, 181 insertions(+), 1 deletion(-) create mode 100755 gather_keys_oauth2.py diff --git a/fitbit/api.py b/fitbit/api.py index 465bc7e..54b61e2 100644 --- a/fitbit/api.py +++ b/fitbit/api.py @@ -2,6 +2,7 @@ import requests import json import datetime +import base64 try: from urllib.parse import urlencode @@ -9,7 +10,7 @@ # Python 2.x from urllib import urlencode -from requests_oauthlib import OAuth1, OAuth1Session +from requests_oauthlib import OAuth1, OAuth1Session, OAuth2, OAuth2Session from fitbit.exceptions import (BadResponse, DeleteError, HTTPBadRequest, HTTPUnauthorized, HTTPForbidden, @@ -142,6 +143,133 @@ def fetch_access_token(self, verifier, token=None): return response +class FitbitOauth2Client(object): + API_ENDPOINT = "https://api.fitbit.com" + AUTHORIZE_ENDPOINT = "https://www.fitbit.com" + API_VERSION = 1 + + request_token_url = "%s/oauth2/token" % API_ENDPOINT + authorization_url = "%s/oauth2/authorize" % AUTHORIZE_ENDPOINT + 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, + resource_owner_key=None, resource_owner_secret=None, user_id=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 + to start the setup for user authorization (as an example see gather_key_oauth2.py) + - client_id, client_secret are in the app configuration page + https://dev.fitbit.com/apps + - access_token, refresh_token are obtained after the user grants permission + - resource_owner_key, resource_owner_secret, user_id are user parameters + """ + + self.session = requests.Session() + self.client_id = client_id + self.client_secret = client_secret + self.resource_owner_key = resource_owner_key + self.resource_owner_secret = resource_owner_secret + self.header = {'Authorization': 'Basic ' + base64.b64encode(client_id +':' + client_secret)} + if user_id: + self.user_id = user_id + + #params = {'client_secret': client_secret} + #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 = OAuth2Session(client_id, **params) + self.oauth = OAuth2Session(client_id) + + 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 = OAuth2( + self.client_id, 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 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. + - 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 + TODO: check if you can give any url and grab code from it + for more info see https://wiki.fitbit.com/display/API/OAuth+2.0 + """ + + if scope: + self.oauth.scope = scope + else: + #self.oauth.scope = {"heartrate", "location"} + self.oauth.scope = "activity nutrition heartrate location nutrition profile settings sleep social weight" + + if redirect_uri: + self.oauth.redirect_uri = redirect_uri + + return self.oauth.authorization_url(self.authorization_url, **kwargs) + + 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 = OAuth2Session( + 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 + + + + class Fitbit(object): US = 'en_US' METRIC = 'en_UK' diff --git a/gather_keys_oauth2.py b/gather_keys_oauth2.py new file mode 100755 index 0000000..2a8731b --- /dev/null +++ b/gather_keys_oauth2.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python +import pprint +import sys +import os + +import interface + +#add the ./python-* folders to paths for ease of importing modules +dirLoc = os.path.dirname(os.path.realpath(__file__)) +fitbitDir = dirLoc + '/python-fitbit/' +sys.path.append(fitbitDir) +from fitbit.api import FitbitOauth2Client + + +if __name__ == '__main__': + + client_id = sys.argv[1] + client_sec = sys.argv[2] + + # setup + pp = pprint.PrettyPrinter(indent=4) + print('** OAuth Python GET KEYS **\n') + client = FitbitOauth2Client(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') + #print(client.authorize_token_url()) + #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('') + #return(token) + + + + + + From 9e554e7e63169424c52e3eece08fe440d990702c Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Wed, 3 Jun 2015 09:07:21 -0700 Subject: [PATCH 26/87] upgrade tox --- requirements/dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index 68ce924..adf0a37 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,4 +1,4 @@ -r base.txt -r test.txt -tox>=1.8,<1.10 +tox>=1.8,<2.1 From fb3b0cacaa35428cd10d169669915ff545d4e87d Mon Sep 17 00:00:00 2001 From: Randi Cabezas Date: Tue, 26 May 2015 11:55:17 -0400 Subject: [PATCH 27/87] working oauth2 (squashing all intermiate commits) --- fitbit/__init__.py | 2 +- fitbit/api.py | 129 +++++++++++++++++++++++++------------- fitbit_tests/__init__.py | 3 +- fitbit_tests/test_auth.py | 110 +++++++++++++++++++++++++++++++- gather_keys_oauth2.py | 63 +++++++++---------- 5 files changed, 224 insertions(+), 83 deletions(-) diff --git a/fitbit/__init__.py b/fitbit/__init__.py index ccd7d36..1bf7f1b 100644 --- a/fitbit/__init__.py +++ b/fitbit/__init__.py @@ -7,7 +7,7 @@ :license: BSD, see LICENSE for more details. """ -from .api import Fitbit, FitbitOauthClient +from .api import Fitbit, FitbitOauthClient, FitbitOauth2Client # Meta. diff --git a/fitbit/api.py b/fitbit/api.py index 54b61e2..740b5c7 100644 --- a/fitbit/api.py +++ b/fitbit/api.py @@ -11,7 +11,8 @@ 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, @@ -155,7 +156,6 @@ class FitbitOauth2Client(object): def __init__(self, client_id , client_secret, access_token=None, refresh_token=None, - resource_owner_key=None, resource_owner_secret=None, user_id=None, *args, **kwargs): """ Create a FitbitOauth2Client object. Specify the first 7 parameters if @@ -164,23 +164,18 @@ def __init__(self, client_id , client_secret, - client_id, client_secret are in the app configuration page https://dev.fitbit.com/apps - access_token, refresh_token are obtained after the user grants permission - - resource_owner_key, resource_owner_secret, user_id are user parameters """ self.session = requests.Session() self.client_id = client_id self.client_secret = client_secret - self.resource_owner_key = resource_owner_key - self.resource_owner_secret = resource_owner_secret - self.header = {'Authorization': 'Basic ' + base64.b64encode(client_id +':' + client_secret)} - if user_id: - self.user_id = user_id + 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} - #params = {'client_secret': client_secret} - #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 = OAuth2Session(client_id, **params) self.oauth = OAuth2Session(client_id) def _request(self, method, url, **kwargs): @@ -191,17 +186,36 @@ def _request(self, method, url, **kwargs): def make_request(self, url, data={}, method=None, **kwargs): """ - Builds and makes the OAuth Request, catches errors + Builds and makes the OAuth2 Request, catches errors https://wiki.fitbit.com/display/API/API+Response+Format+And+Errors """ if not method: method = 'POST' if data else 'GET' - auth = OAuth2( - self.client_id, 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) - + + 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: @@ -229,43 +243,57 @@ def authorize_token_url(self, scope=None, redirect_uri=None, **kwargs): - 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 - TODO: check if you can give any url and grab code from it 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 = {"heartrate", "location"} - self.oauth.scope = "activity nutrition heartrate location nutrition profile settings sleep social weight" + self.oauth.scope =["activity", "nutrition","heartrate","location", "nutrition","profile","settings","sleep","social","weight"] if redirect_uri: self.oauth.redirect_uri = redirect_uri - - return self.oauth.authorization_url(self.authorization_url, **kwargs) + - 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 + out = self.oauth.authorization_url(self.authorization_url, **kwargs) + self.oauth.scope = old_scope + self.oauth.redirect_uri = old_redirect + return(out) + + def fetch_access_token(self, code, redirect_uri): + + """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 """ - if token: - self.resource_owner_key = token.get('oauth_token') - self.resource_owner_secret = token.get('oauth_token_secret') + auth = OAuth2Session(self.client_id, redirect_uri=redirect_uri) + self.token = auth.fetch_token(self.access_token_url, headers=self.auth_header, code=code) - self.oauth = OAuth2Session( - 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) + return self.token - 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 + 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) @@ -296,10 +324,23 @@ class Fitbit(object): 'frequent', ] - def __init__(self, client_key, client_secret, system=US, **kwargs): - self.client = FitbitOauthClient(client_key, client_secret, **kwargs) + def __init__(self, client_key=None, client_secret=None, client_id=None, system=US, **kwargs): + """ + pleasse provide either client_key/client_secret to use OAuth1 + pleasse provide either client_id/client_secret to use OAuth2 + kwargs can be used to provide parameters: + oath1: Fitbit(, ,resource_owner_key=, resource_owner_secret=) + oath2: Fitbit(client_id=, ,access_token=, refresh_token=) + """ self.system = system + if (client_key is not None) or kwargs.has_key('client_key'): + self.client = FitbitOauthClient(client_key, client_secret, **kwargs) + elif (client_id is not None) or kwargs.has_key('client_id'): + self.client = FitbitOauth2Client(client_id, client_secret, **kwargs) + else: + raise TypeError("Please specify either client_key (oauth1) or client_id (oauth2)") + # All of these use the same patterns, define the method for accessing # creating and deleting records once, and use curry to make individual # Methods for each diff --git a/fitbit_tests/__init__.py b/fitbit_tests/__init__.py index ae6932b..34895ec 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 +from .test_auth import AuthTest, Auth2Test from .test_api import ( APITest, CollectionResourceTest, @@ -21,6 +21,7 @@ def all_tests(consumer_key="", consumer_secret="", user_key=None, user_secret=No 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)) suite.addTest(unittest.makeSuite(DeleteCollectionResourceTest)) diff --git a/fitbit_tests/test_auth.py b/fitbit_tests/test_auth.py index 8f83b75..16d479a 100644 --- a/fitbit_tests/test_auth.py +++ b/fitbit_tests/test_auth.py @@ -1,7 +1,8 @@ from unittest import TestCase -from fitbit import Fitbit, FitbitOauthClient +from fitbit import Fitbit, FitbitOauthClient, FitbitOauth2Client import mock -from requests_oauthlib import OAuth1Session +from requests_oauthlib import OAuth1Session, OAuth2Session +from oauthlib.oauth2 import TokenExpiredError class AuthTest(TestCase): """Add tests for auth part of API @@ -64,3 +65,108 @@ def test_fetch_access_token(self): 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) + + +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 + """ + client_kwargs = { + 'client_id': 'fake_id', + 'client_secret': 'fake_secret', + 'callback_uri': 'fake_callback_url', + 'scope': ['fake_scope1'] + } + def test_authorize_token_url(self): + # authorize_token_url calls oauth and returns a URL + client = FitbitOauth2Client(**self.client_kwargs) + retval = 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]) + + def test_authorize_token_url_with_parameters(self): + # authorize_token_url calls oauth and returns a URL + client = FitbitOauth2Client(**self.client_kwargs) + retval = 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']) + + + def test_fetch_access_token(self): + # tests the fetching of access token using code and redirect_URL + kwargs = self.client_kwargs + client = FitbitOauth2Client(**kwargs) + fake_code = "fake_code" + with mock.patch.object(OAuth2Session, 'fetch_token') as fat: + fat.return_value = { + 'access_token': 'fake_return_access_token', + 'refresh_token': 'fake_return_refresh_token' + } + retval = client.fetch_access_token(fake_code,kwargs['callback_uri']) + 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['access_token'] = 'fake_access_token' + kwargs['refresh_token'] = 'fake_refresh_token' + client = FitbitOauth2Client(**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"}') + retval = 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 + # 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' + + client = FitbitOauth2Client(**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 = client.make_request(Fitbit.API_ENDPOINT + '/1/user/-/profile.json') + self.assertEqual("correct_response", retval.text) + self.assertEqual("fake_return_access_token", client.token['access_token']) + self.assertEqual("fake_return_refresh_token",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 + # 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' + + client = FitbitOauth2Client(**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 = client.make_request(Fitbit.API_ENDPOINT + '/1/user/-/profile.json') + self.assertEqual("correct_response", retval.text) + self.assertEqual("fake_return_access_token", client.token['access_token']) + self.assertEqual("fake_return_refresh_token",client.token['refresh_token']) + self.assertEqual(1, auth.call_count) + self.assertEqual(2, r.call_count) + + +class fake_response(object): + def __init__(self,code,text): + self.status_code = code + self.text = text + self.content = text diff --git a/gather_keys_oauth2.py b/gather_keys_oauth2.py index 2a8731b..a69acf8 100755 --- a/gather_keys_oauth2.py +++ b/gather_keys_oauth2.py @@ -3,50 +3,43 @@ import sys import os -import interface - -#add the ./python-* folders to paths for ease of importing modules -dirLoc = os.path.dirname(os.path.realpath(__file__)) -fitbitDir = dirLoc + '/python-fitbit/' -sys.path.append(fitbitDir) from fitbit.api import FitbitOauth2Client -if __name__ == '__main__': - - client_id = sys.argv[1] - client_sec = sys.argv[2] - +def gather_keys(client_id,client_secret, redirect_uri): + # setup pp = pprint.PrettyPrinter(indent=4) - print('** OAuth Python GET KEYS **\n') - client = FitbitOauth2Client(client.key, client.secret) + client = FitbitOauth2Client(client_id, client_secret) + + #get authorization url + url = client.authorize_token_url(redirect_uri=redirect_uri) - ## 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\nCopy code here\n') + print(url) + try: + verifier = raw_input('Code: ') + except NameError: + # Python 3.x + verifier = input('Code: ') - #print('* Authorize the request token in your browser\n') - #print(client.authorize_token_url()) - #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,redirect_uri) + print('RESPONSE') + pp.pprint(token) + print('') + return(token) - ## get access token - #print('\n* Obtain an access token ...\n') - #token = client.fetch_access_token(verifier) - #print('RESPONSE') - #pp.pprint(token) - #print('') - #return(token) +if __name__ == '__main__': + if not (len(sys.argv) == 4): + print "Arguments: client_id, client_secret, and redirect_uri" + sys.exit(1) + client_id = sys.argv[1] + client_sec = sys.argv[2] + redirect_uri = sys.argv[3] - - + token = gather_keys(client_id,client_sec,redirect_uri) From 3abe82271b0df72b4697d96212bfe69d81ec884f Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Fri, 12 Jun 2015 22:33:26 -0700 Subject: [PATCH 28/87] use a cherrypy server for easy token collection --- gather_keys_oauth2.py | 96 +++++++++++++++++++++++++++++-------------- requirements/dev.txt | 1 + 2 files changed, 66 insertions(+), 31 deletions(-) diff --git a/gather_keys_oauth2.py b/gather_keys_oauth2.py index a69acf8..1060fc6 100755 --- a/gather_keys_oauth2.py +++ b/gather_keys_oauth2.py @@ -1,45 +1,79 @@ #!/usr/bin/env python -import pprint -import sys +import cherrypy import os +import sys +import threading +import traceback +import webbrowser -from fitbit.api import FitbitOauth2Client +from base64 import b64encode +from fitbit.api import FitbitOauth2Client +from oauthlib.oauth2.rfc6749.errors import MismatchingStateError, MissingTokenError +from requests_oauthlib import OAuth2Session -def gather_keys(client_id,client_secret, redirect_uri): - - # setup - pp = pprint.PrettyPrinter(indent=4) - client = FitbitOauth2Client(client_id, client_secret) +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) - #get authorization url - url = client.authorize_token_url(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) + # Open the web browser in a new thread for command-line browser support + threading.Timer(1, webbrowser.open, args=(url,)).start() + cherrypy.quickstart(self) - print('* Authorize the request token in your browser\nCopy code here\n') - print(url) - try: - verifier = raw_input('Code: ') - except NameError: - # Python 3.x - verifier = input('Code: ') + @cherrypy.expose + def index(self, state, code=None, error=None): + """ + Receive a Fitbit response containing a verification code. Use the code + to fetch the access_token. + """ + error = None + if code: + try: + self.oauth.fetch_access_token(code, self.redirect_uri) + except MissingTokenError: + error = self._fmt_failure( + 'Missing access token parameter.
Please check that ' + 'you are using the correct client_secret') + except MismatchingStateError: + error = self._fmt_failure('CSRF Warning! Mismatching state') + else: + error = self._fmt_failure('Unknown error while authenticating') + # Use a thread to shutdown cherrypy so we can return HTML first + self._shutdown_cherrypy() + return error if error else self.success_html - # get access token - print('\n* Obtain an access token ...\n') - token = client.fetch_access_token(verifier,redirect_uri) - print('RESPONSE') - pp.pprint(token) - print('') - return(token) + def _fmt_failure(self, message): + tb = traceback.format_tb(sys.exc_info()[2]) + tb_html = '
%s
' % ('\n'.join(tb)) if tb else '' + return self.failure_html % (message, tb_html) + + def _shutdown_cherrypy(self): + """ Shutdown cherrypy in one second, if it's running """ + if cherrypy.engine.state == cherrypy.engine.states.STARTED: + threading.Timer(1, cherrypy.engine.exit).start() if __name__ == '__main__': - if not (len(sys.argv) == 4): - print "Arguments: client_id, client_secret, and redirect_uri" + if not (len(sys.argv) == 3): + print("Arguments: client_id and client_secret") sys.exit(1) - client_id = sys.argv[1] - client_sec = sys.argv[2] - redirect_uri = sys.argv[3] - - token = gather_keys(client_id,client_sec,redirect_uri) + server = OAuth2Server(*sys.argv[1:]) + server.browser_authorize() + print('FULL RESULTS = %s' % server.oauth.token) + print('ACCESS_TOKEN = %s' % server.oauth.token['access_token']) diff --git a/requirements/dev.txt b/requirements/dev.txt index 4ec8b19..ce26865 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,4 +1,5 @@ -r base.txt -r test.txt +cherrypy>=3.7,<3.8 tox>=1.8,<1.9 From f7cc3cb69883568e366bda640d5270a923163a6c Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Fri, 12 Jun 2015 22:36:13 -0700 Subject: [PATCH 29/87] alternative oauth2 API --- fitbit/api.py | 19 ++++++---------- fitbit_tests/test_auth.py | 48 +++++++++++++++++++-------------------- 2 files changed, 31 insertions(+), 36 deletions(-) diff --git a/fitbit/api.py b/fitbit/api.py index 740b5c7..72d253c 100644 --- a/fitbit/api.py +++ b/fitbit/api.py @@ -324,23 +324,18 @@ class Fitbit(object): 'frequent', ] - def __init__(self, client_key=None, client_secret=None, client_id=None, system=US, **kwargs): + def __init__(self, client_key, client_secret, oauth2=False, system=US, **kwargs): """ - pleasse provide either client_key/client_secret to use OAuth1 - pleasse provide either client_id/client_secret to use OAuth2 - kwargs can be used to provide parameters: - oath1: Fitbit(, ,resource_owner_key=, resource_owner_secret=) - oath2: Fitbit(client_id=, ,access_token=, refresh_token=) + oauth1: Fitbit(, , resource_owner_key=, resource_owner_secret=) + oauth2: Fitbit(, , oauth2=True, access_token=, refresh_token=) """ self.system = system - if (client_key is not None) or kwargs.has_key('client_key'): - self.client = FitbitOauthClient(client_key, client_secret, **kwargs) - elif (client_id is not None) or kwargs.has_key('client_id'): - self.client = FitbitOauth2Client(client_id, client_secret, **kwargs) + if oauth2: + self.client = FitbitOauth2Client(client_key, client_secret, **kwargs) else: - raise TypeError("Please specify either client_key (oauth1) or client_id (oauth2)") - + self.client = FitbitOauthClient(client_key, client_secret, **kwargs) + # All of these use the same patterns, define the method for accessing # creating and deleting records once, and use curry to make individual # Methods for each diff --git a/fitbit_tests/test_auth.py b/fitbit_tests/test_auth.py index 16d479a..e3ecca6 100644 --- a/fitbit_tests/test_auth.py +++ b/fitbit_tests/test_auth.py @@ -73,36 +73,36 @@ class Auth2Test(TestCase): make sure we call the right oauth calls, respond correctly based on the responses """ client_kwargs = { - 'client_id': 'fake_id', + 'client_key': 'fake_id', 'client_secret': 'fake_secret', 'callback_uri': 'fake_callback_url', + 'oauth2': True, 'scope': ['fake_scope1'] } def test_authorize_token_url(self): # authorize_token_url calls oauth and returns a URL - client = FitbitOauth2Client(**self.client_kwargs) - retval = client.authorize_token_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]) def test_authorize_token_url_with_parameters(self): # authorize_token_url calls oauth and returns a URL - client = FitbitOauth2Client(**self.client_kwargs) - retval = client.authorize_token_url(scope=self.client_kwargs['scope'], + 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']) def test_fetch_access_token(self): # tests the fetching of access token using code and redirect_URL - kwargs = self.client_kwargs - client = FitbitOauth2Client(**kwargs) + fb = Fitbit(**self.client_kwargs) fake_code = "fake_code" with mock.patch.object(OAuth2Session, 'fetch_token') as fat: fat.return_value = { 'access_token': 'fake_return_access_token', 'refresh_token': 'fake_return_refresh_token' } - retval = client.fetch_access_token(fake_code,kwargs['callback_uri']) + retval = fb.client.fetch_access_token(fake_code, self.client_kwargs['callback_uri']) self.assertEqual("fake_return_access_token", retval['access_token']) self.assertEqual("fake_return_refresh_token", retval['refresh_token']) @@ -112,55 +112,55 @@ def test_refresh_token(self): kwargs = self.client_kwargs kwargs['access_token'] = 'fake_access_token' kwargs['refresh_token'] = 'fake_refresh_token' - client = FitbitOauth2Client(**kwargs) + 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"}') - retval = client.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 + # test of auto_refersh with tokenExpired exception # 1. first call to _request causes a TokenExpired # 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' - - client = FitbitOauth2Client(**kwargs) + + 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 = client.make_request(Fitbit.API_ENDPOINT + '/1/user/-/profile.json') + retval = fb.client.make_request(Fitbit.API_ENDPOINT + '/1/user/-/profile.json') self.assertEqual("correct_response", retval.text) - self.assertEqual("fake_return_access_token", client.token['access_token']) - self.assertEqual("fake_return_refresh_token",client.token['refresh_token']) + 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 + # test of auto_refersh when the exception doesn't fire + # 1. first call to _request causes a 401 expired token response # 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' - - client = FitbitOauth2Client(**kwargs) + + 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"}]}'), + 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 = client.make_request(Fitbit.API_ENDPOINT + '/1/user/-/profile.json') + retval = fb.client.make_request(Fitbit.API_ENDPOINT + '/1/user/-/profile.json') self.assertEqual("correct_response", retval.text) - self.assertEqual("fake_return_access_token", client.token['access_token']) - self.assertEqual("fake_return_refresh_token",client.token['refresh_token']) + 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) From 50a83b24154d74bb7c491fc9e1969cd1c820e377 Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Sat, 13 Jun 2015 20:30:05 -0700 Subject: [PATCH 30/87] switch to alabaster sphinx theme --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index e7ef8b4..e1715a4 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -95,7 +95,7 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'classic' +html_theme = 'alabaster' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the From e3781331731525c8a482258519e3fc277fa24355 Mon Sep 17 00:00:00 2001 From: jliphard Date: Sun, 14 Jun 2015 14:35:24 -0700 Subject: [PATCH 31/87] Updated the parameter check to accommodate the '1sec' value for activity/heart --- fitbit/api.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/fitbit/api.py b/fitbit/api.py index 72d253c..991581b 100644 --- a/fitbit/api.py +++ b/fitbit/api.py @@ -530,8 +530,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://wiki.fitbit.com/display/API/API-Get-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(), From 1112ee9f1460ae87a51c4db29739f6830da12be0 Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Sun, 14 Jun 2015 22:42:02 -0700 Subject: [PATCH 32/87] add support for getting/updating goals --- fitbit/api.py | 131 +++++++++++++++++++++++++++++++++++++++ fitbit_tests/test_api.py | 72 ++++++++++++++++++++- 2 files changed, 200 insertions(+), 3 deletions(-) diff --git a/fitbit/api.py b/fitbit/api.py index 991581b..3730e3d 100644 --- a/fitbit/api.py +++ b/fitbit/api.py @@ -483,6 +483,137 @@ 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://wiki.fitbit.com/display/API/API-Get-Body-Fat + * https://wiki.fitbit.com/display/API/API-Update-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://wiki.fitbit.com/display/API/API-Get-Body-Weight-Goal + * https://wiki.fitbit.com/display/API/API-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 + + https://wiki.fitbit.com/display/API/API-Get-Activity-Daily-Goals + https://wiki.fitbit.com/display/API/API-Update-Activity-Daily-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 + + https://wiki.fitbit.com/display/API/API-Get-Activity-Weekly-Goals + https://wiki.fitbit.com/display/API/API-Update-Activity-Weekly-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://wiki.fitbit.com/display/API/API-Get-Food-Goals + https://wiki.fitbit.com/display/API/API-Update-Food-Goals + + 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://wiki.fitbit.com/display/API/API-Get-Water-Goal + https://wiki.fitbit.com/display/API/API-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): """ diff --git a/fitbit_tests/test_api.py b/fitbit_tests/test_api.py index e94797b..7a34d5b 100644 --- a/fitbit_tests/test_api.py +++ b/fitbit_tests/test_api.py @@ -16,9 +16,9 @@ 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) @@ -250,6 +250,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' From d747b12ee285e4a2d748d9abef0989bad320c980 Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Fri, 9 Oct 2015 14:13:46 -0700 Subject: [PATCH 33/87] remove trailing spaces --- fitbit/api.py | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/fitbit/api.py b/fitbit/api.py index 3730e3d..ff20bcc 100644 --- a/fitbit/api.py +++ b/fitbit/api.py @@ -154,7 +154,7 @@ class FitbitOauth2Client(object): access_token_url = request_token_url refresh_token_url = request_token_url - def __init__(self, client_id , client_secret, + def __init__(self, client_id , client_secret, access_token=None, refresh_token=None, *args, **kwargs): """ @@ -170,9 +170,9 @@ def __init__(self, client_id , client_secret, 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')) + 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} @@ -192,30 +192,30 @@ def make_request(self, url, data={}, method=None, **kwargs): """ 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: + 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 + #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 + 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: @@ -239,25 +239,25 @@ 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; + old_redirect = self.oauth.redirect_uri; if scope: self.oauth.scope = scope - else: + else: self.oauth.scope =["activity", "nutrition","heartrate","location", "nutrition","profile","settings","sleep","social","weight"] if redirect_uri: self.oauth.redirect_uri = redirect_uri - + out = self.oauth.authorization_url(self.authorization_url, **kwargs) self.oauth.scope = old_scope @@ -266,7 +266,7 @@ def authorize_token_url(self, scope=None, redirect_uri=None, **kwargs): def fetch_access_token(self, code, redirect_uri): - """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 @@ -274,11 +274,11 @@ def fetch_access_token(self, code, redirect_uri): 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 + return self.token def refresh_token(self): - """Step 3: obtains a new access_token from the the refresh token - obtained in step 2. + """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) @@ -286,7 +286,7 @@ def refresh_token(self): #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) From 1d9e76499f9b4701feb57ef208a35b547cdc6626 Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Fri, 9 Oct 2015 14:14:36 -0700 Subject: [PATCH 34/87] add support for python 3.5 --- .travis.yml | 8 ++++++-- setup.py | 1 + tox.ini | 5 ++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 1ad4a10..c6f30eb 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,11 @@ language: python -python: 3.4 +python: 3.5 env: - - TOX_ENV=pypy + # Avoid testing pypy on travis until the following issue is fixed: + # https://github.com/travis-ci/travis-ci/issues/4756 + #- TOX_ENV=pypy + - TOX_ENV=py35 + - TOX_ENV=py34 - TOX_ENV=py33 - TOX_ENV=py32 - TOX_ENV=py27 diff --git a/setup.py b/setup.py index 8dbbdb4..08826a2 100644 --- a/setup.py +++ b/setup.py @@ -39,6 +39,7 @@ 'Programming Language :: Python :: 3.2', 'Programming Language :: Python :: 3.3', 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: Implementation :: PyPy' ), ) diff --git a/tox.ini b/tox.ini index 5b824df..3d0a46d 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = pypy,py34,py33,py32,py27,py26,docs +envlist = pypy,py35,py34,py33,py32,py27,py26,docs [testenv] commands = coverage run --source=fitbit setup.py test @@ -8,6 +8,9 @@ deps = -r{toxinidir}/requirements/test.txt [testenv:pypy] basepython = pypy +[testenv:py35] +basepython = python3.5 + [testenv:py34] basepython = python3.4 From f0166f6b0f90f77e4361facdbecad925b61bbf8e Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Fri, 9 Oct 2015 14:19:37 -0700 Subject: [PATCH 35/87] update requirements --- requirements/base.txt | 2 +- requirements/dev.txt | 4 ++-- requirements/test.txt | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements/base.txt b/requirements/base.txt index 93e4096..faab5be 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 +requests-oauthlib>=0.4,<1.1 diff --git a/requirements/dev.txt b/requirements/dev.txt index 3cb7c01..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,<2.1 +cherrypy>=3.7,<3.9 +tox>=1.8,<2.2 diff --git a/requirements/test.txt b/requirements/test.txt index 3c4f925..d5c6230 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -1,3 +1,3 @@ -mock>=1.0,<1.1 -coverage>=3.7,<3.8 +mock>=1.0,<1.4 +coverage>=3.7,<4.0 Sphinx>=1.2,<1.4 From b3b030293f1e26d82689e0b21b9445cede551ddd Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Thu, 31 Dec 2015 23:13:27 -0800 Subject: [PATCH 36/87] get oauth2 working again, #70 --- fitbit/api.py | 41 ++++++++++++++++++--------------------- fitbit_tests/test_auth.py | 31 ++++++++++++++++++----------- gather_keys_oauth2.py | 2 ++ 3 files changed, 41 insertions(+), 33 deletions(-) diff --git a/fitbit/api.py b/fitbit/api.py index ff20bcc..6498f7a 100644 --- a/fitbit/api.py +++ b/fitbit/api.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- -import requests -import json -import datetime import base64 +import datetime +import json +import requests try: from urllib.parse import urlencode @@ -169,13 +169,8 @@ def __init__(self, client_id , client_secret, 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) def _request(self, method, url, **kwargs): @@ -272,7 +267,11 @@ def fetch_access_token(self, code, redirect_uri): 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) + self.token = auth.fetch_token( + self.access_token_url, + username=self.client_id, + password=self.client_secret, + code=code) return self.token @@ -281,19 +280,17 @@ def refresh_token(self): 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) + + unenc_str = (self.client_id + ':' + self.client_secret).encode('utf8') + headers = { + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', + 'Authorization': b'Basic ' + base64.b64encode(unenc_str) + } + self.token = self.oauth.refresh_token( + self.refresh_token_url, + refresh_token=self.token['refresh_token'], + headers=headers) + return self.token diff --git a/fitbit_tests/test_auth.py b/fitbit_tests/test_auth.py index e3ecca6..fb3f78f 100644 --- a/fitbit_tests/test_auth.py +++ b/fitbit_tests/test_auth.py @@ -113,15 +113,18 @@ def test_refresh_token(self): kwargs['access_token'] = 'fake_access_token' kwargs['refresh_token'] = 'fake_refresh_token' 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 mock.patch.object(OAuth2Session, 'refresh_token') as rt: + rt.return_value = { + '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 + # test of auto_refresh with tokenExpired exception # 1. first call to _request causes a TokenExpired # 2. the token_refresh call is faked # 3. the second call to _request returns a valid value @@ -132,13 +135,16 @@ def test_auto_refresh_token_exception(self): 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"}') + with mock.patch.object(OAuth2Session, 'refresh_token') as rt: + rt.return_value = { + 'access_token': 'fake_return_access_token', + '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(1, rt.call_count) self.assertEqual(2, r.call_count) @@ -155,18 +161,21 @@ def test_auto_refresh_token_nonException(self): 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"}') + with mock.patch.object(OAuth2Session, 'refresh_token') as rt: + rt.return_value = { + 'access_token': 'fake_return_access_token', + '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(1, rt.call_count) self.assertEqual(2, r.call_count) 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/gather_keys_oauth2.py b/gather_keys_oauth2.py index 1060fc6..7188644 100755 --- a/gather_keys_oauth2.py +++ b/gather_keys_oauth2.py @@ -75,5 +75,7 @@ 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']) + print('REFRESH_TOKEN = %s' % server.oauth.token['refresh_token']) From 184b1f403d8d4195be5925b1414640e4dca6b7f9 Mon Sep 17 00:00:00 2001 From: Chase Date: Fri, 1 Jan 2016 17:07:00 -0500 Subject: [PATCH 37/87] Add OAuth2 example to docs --- docs/index.rst | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/index.rst b/docs/index.rst index e3570f3..b4fb5f9 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -14,18 +14,28 @@ measurements Quickstart ========== -Here is some example usage:: +If you are only retrieving data that doesn't require authorization, then you can use the unauthorized interface:: import fitbit unauth_client = fitbit.Fitbit('', '') # certain methods do not require user keys unauth_client.food_units() +Here is an example of authorizing with OAuth 1.0:: + # 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='') authd_client.sleep() +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('', '', oauth2=True, + access_token='', refresh_token='') + authd_client.sleep() + Fitbit API ========== From ce1fe5e1dc679270e3c357dcda3ff9955bfb3314 Mon Sep 17 00:00:00 2001 From: Matt Shen Date: Wed, 13 Jan 2016 14:59:35 -0800 Subject: [PATCH 38/87] Filter empty requirements so pkg_resources works --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 08826a2..7fe1232 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) From 7e7dd9d5a54e3811f7a40e0690052d0d58641903 Mon Sep 17 00:00:00 2001 From: Percy Perez Date: Wed, 16 Mar 2016 09:14:39 -0700 Subject: [PATCH 39/87] [#105509372] PEP style fixes --- fitbit/api.py | 67 +++++++++++++++++++++++++++++---------------------- 1 file changed, 38 insertions(+), 29 deletions(-) diff --git a/fitbit/api.py b/fitbit/api.py index 6498f7a..c0954a2 100644 --- a/fitbit/api.py +++ b/fitbit/api.py @@ -12,7 +12,6 @@ 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, @@ -154,9 +153,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, + *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 @@ -169,8 +168,10 @@ def __init__(self, client_id , client_secret, self.session = requests.Session() self.client_id = client_id self.client_secret = client_secret - self.token = {'access_token' : access_token, - 'refresh_token': refresh_token} + self.token = { + 'access_token': access_token, + 'refresh_token': refresh_token + } self.oauth = OAuth2Session(client_id) def _request(self, method, url, **kwargs): @@ -196,14 +197,14 @@ def make_request(self, url, data={}, method=None, **kwargs): 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) + # 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 + 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) @@ -241,19 +242,21 @@ def authorize_token_url(self, scope=None, redirect_uri=None, **kwargs): 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; + # 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 + self.oauth.scope = scope else: - self.oauth.scope =["activity", "nutrition","heartrate","location", "nutrition","profile","settings","sleep","social","weight"] + self.oauth.scope = [ + "activity", "nutrition", "heartrate", "location", "nutrition", + "profile", "settings", "sleep", "social", "weight" + ] if redirect_uri: self.oauth.redirect_uri = redirect_uri - out = self.oauth.authorization_url(self.authorization_url, **kwargs) self.oauth.scope = old_scope self.oauth.redirect_uri = old_redirect @@ -293,8 +296,6 @@ def refresh_token(self): return self.token - - class Fitbit(object): US = 'en_US' METRIC = 'en_UK' @@ -352,7 +353,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}) @@ -525,8 +526,11 @@ def body_weight_goal(self, start_date=None, start_weight=None, weight=None): * ``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}) + 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) @@ -550,9 +554,13 @@ def activities_daily_goal(self, calories_out=None, active_minutes=None, * ``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}) + 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): @@ -747,7 +755,7 @@ def log_activity(self, data): https://wiki.fitbit.com/display/API/API-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): """ @@ -810,8 +818,9 @@ 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 alarm_time should be a timezone aware datetime object. From a97cb47f824c6a8c911c2c94be7dc810f450b437 Mon Sep 17 00:00:00 2001 From: Percy Perez Date: Thu, 17 Mar 2016 13:58:10 -0700 Subject: [PATCH 40/87] [#105509372] Keep OAuth2 support only and fix test --- docs/index.rst | 9 +- fitbit/__init__.py | 6 +- fitbit/api.py | 141 ++------------------------------ fitbit_tests/__init__.py | 9 +- fitbit_tests/test_api.py | 45 ++++++---- fitbit_tests/test_auth.py | 87 +++----------------- fitbit_tests/test_exceptions.py | 12 +-- 7 files changed, 56 insertions(+), 253 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index b4fb5f9..d773a73 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -21,18 +21,11 @@ If you are only retrieving data that doesn't require authorization, then you can # certain methods do not require user keys unauth_client.food_units() -Here is an example of authorizing with OAuth 1.0:: - - # 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='') - authd_client.sleep() - 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('', '', oauth2=True, + authd_client = fitbit.Fitbit('', '', access_token='', refresh_token='') authd_client.sleep() diff --git a/fitbit/__init__.py b/fitbit/__init__.py index 1bf7f1b..aa8cf36 100644 --- a/fitbit/__init__.py +++ b/fitbit/__init__.py @@ -7,7 +7,7 @@ :license: BSD, see LICENSE for more details. """ -from .api import Fitbit, FitbitOauthClient, FitbitOauth2Client +from .api import Fitbit, FitbitOauth2Client # Meta. @@ -17,8 +17,8 @@ __copyright__ = 'Copyright 2012-2015 ORCAS' __license__ = 'Apache 2.0' -__version__ = '0.1.3' -__release__ = '0.1.3' +__version__ = '0.2' +__release__ = '0.2' # Module namespace. diff --git a/fitbit/api.py b/fitbit/api.py index c0954a2..ab6cf4d 100644 --- a/fitbit/api.py +++ b/fitbit/api.py @@ -10,7 +10,7 @@ # Python 2.x from urllib import urlencode -from requests_oauthlib import OAuth1, OAuth1Session, OAuth2, OAuth2Session +from requests_oauthlib import OAuth2, OAuth2Session from oauthlib.oauth2 import TokenExpiredError from fitbit.exceptions import (BadResponse, DeleteError, HTTPBadRequest, HTTPUnauthorized, HTTPForbidden, @@ -19,130 +19,6 @@ 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) - - 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 - - class FitbitOauth2Client(object): API_ENDPOINT = "https://api.fitbit.com" AUTHORIZE_ENDPOINT = "https://www.fitbit.com" @@ -205,7 +81,7 @@ def make_request(self, url, data={}, method=None, **kwargs): 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): + 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) @@ -322,17 +198,12 @@ class Fitbit(object): 'frequent', ] - def __init__(self, client_key, client_secret, oauth2=False, system=US, **kwargs): + def __init__(self, client_key, client_secret, 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_key, client_secret, **kwargs) # All of these use the same patterns, define the method for accessing # creating and deleting records once, and use curry to make individual @@ -1115,9 +986,11 @@ def list_subscriptions(self, collection=''): ) 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_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 7a34d5b..651a189 100644 --- a/fitbit_tests/test_api.py +++ b/fitbit_tests/test_api.py @@ -34,7 +34,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 +54,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 +67,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 +80,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 +93,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 +104,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 +125,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 +144,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 +169,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 +193,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 @@ -713,12 +722,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 fb3f78f..dfc7271 100644 --- a/fitbit_tests/test_auth.py +++ b/fitbit_tests/test_auth.py @@ -1,71 +1,9 @@ from unittest import TestCase -from fitbit import Fitbit, FitbitOauthClient, FitbitOauth2Client +from fitbit import Fitbit, FitbitOauth2Client import mock -from requests_oauthlib import OAuth1Session, OAuth2Session +from requests_oauthlib import OAuth2Session from oauthlib.oauth2 import TokenExpiredError -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) - - 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) - class Auth2Test(TestCase): """Add tests for auth part of API @@ -76,22 +14,22 @@ class Auth2Test(TestCase): 'client_key': 'fake_id', 'client_secret': 'fake_secret', 'callback_uri': 'fake_callback_url', - 'oauth2': True, '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&scope=activity+nutrition+heartrate+location+nutrition+profile+settings+sleep+social+weight&state='+retval[1]) def test_authorize_token_url_with_parameters(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'], + 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']) def test_fetch_access_token(self): # tests the fetching of access token using code and redirect_URL @@ -106,7 +44,6 @@ def test_fetch_access_token(self): 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 @@ -122,7 +59,6 @@ def test_refresh_token(self): 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_refresh with tokenExpired exception # 1. first call to _request causes a TokenExpired @@ -134,7 +70,7 @@ def test_auto_refresh_token_exception(self): fb = Fitbit(**kwargs) with mock.patch.object(FitbitOauth2Client, '_request') as r: - r.side_effect = [TokenExpiredError, fake_response(200,'correct_response')] + r.side_effect = [TokenExpiredError, fake_response(200, 'correct_response')] with mock.patch.object(OAuth2Session, 'refresh_token') as rt: rt.return_value = { 'access_token': 'fake_return_access_token', @@ -147,7 +83,6 @@ def test_auto_refresh_token_exception(self): self.assertEqual(1, rt.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 @@ -159,8 +94,8 @@ def test_auto_refresh_token_nonException(self): 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')] + 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, 'refresh_token') as rt: rt.return_value = { 'access_token': 'fake_return_access_token', diff --git a/fitbit_tests/test_exceptions.py b/fitbit_tests/test_exceptions.py index 2b87e9a..425727c 100644 --- a/fitbit_tests/test_exceptions.py +++ b/fitbit_tests/test_exceptions.py @@ -5,6 +5,7 @@ from fitbit import Fitbit from fitbit import exceptions + class ExceptionTest(unittest.TestCase): """ Tests that certain response codes raise certain exceptions @@ -12,8 +13,8 @@ class ExceptionTest(unittest.TestCase): client_kwargs = { "client_key": "", "client_secret": "", - "user_key": None, - "user_secret": None, + "access_token": None, + "refresh_token": None } def test_response_ok(self): @@ -36,7 +37,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 +44,7 @@ def test_response_auth(self): """ r = mock.Mock(spec=requests.Response) r.status_code = 401 - r.content = b"{'normal': 'resource'}" + r.content = b'{"normal": "resource"}' f = Fitbit(**self.client_kwargs) f.client._request = lambda *args, **kwargs: r @@ -54,14 +54,14 @@ def test_response_auth(self): r.status_code = 403 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 From ba02d1504704c75e19bd9d1c14b7f833f14f7052 Mon Sep 17 00:00:00 2001 From: Percy Perez Date: Thu, 17 Mar 2016 14:24:59 -0700 Subject: [PATCH 41/87] [#105509372] Drop py32 support --- tox.ini | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tox.ini b/tox.ini index 3d0a46d..7762db7 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = pypy,py35,py34,py33,py32,py27,py26,docs +envlist = pypy,py35,py34,py33,py27,py26,docs [testenv] commands = coverage run --source=fitbit setup.py test @@ -17,9 +17,6 @@ basepython = python3.4 [testenv:py33] basepython = python3.3 -[testenv:py32] -basepython = python3.2 - [testenv:py27] basepython = python2.7 From 706f54ad5fc6bdc37381be2c916a6335f4dd5bb8 Mon Sep 17 00:00:00 2001 From: Percy Perez Date: Wed, 23 Mar 2016 11:16:25 -0700 Subject: [PATCH 42/87] [#105509372] Delete gather_keys_cli.py file --- .travis.yml | 1 - fitbit/api.py | 9 ----- gather_keys_cli.py | 83 ---------------------------------------------- 3 files changed, 93 deletions(-) delete mode 100755 gather_keys_cli.py diff --git a/.travis.yml b/.travis.yml index c6f30eb..08c1e76 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,7 +7,6 @@ env: - TOX_ENV=py35 - TOX_ENV=py34 - TOX_ENV=py33 - - TOX_ENV=py32 - TOX_ENV=py27 - TOX_ENV=py26 - TOX_ENV=docs diff --git a/fitbit/api.py b/fitbit/api.py index ab6cf4d..dd24f35 100644 --- a/fitbit/api.py +++ b/fitbit/api.py @@ -985,12 +985,3 @@ def list_subscriptions(self, collection=''): 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/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.') From e1c338b2ac13094fbd95c163410c9c59f6cd985e Mon Sep 17 00:00:00 2001 From: Percy Perez Date: Wed, 23 Mar 2016 11:22:52 -0700 Subject: [PATCH 43/87] Drop python 2.6 support --- .travis.yml | 1 - tox.ini | 5 +---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/.travis.yml b/.travis.yml index 08c1e76..9c50862 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,7 +8,6 @@ env: - TOX_ENV=py34 - TOX_ENV=py33 - TOX_ENV=py27 - - TOX_ENV=py26 - TOX_ENV=docs install: - pip install coveralls tox diff --git a/tox.ini b/tox.ini index 7762db7..279b114 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = pypy,py35,py34,py33,py27,py26,docs +envlist = pypy,py35,py34,py33,py27,docs [testenv] commands = coverage run --source=fitbit setup.py test @@ -20,9 +20,6 @@ basepython = python3.3 [testenv:py27] basepython = python2.7 -[testenv:py26] -basepython = python2.6 - [testenv:docs] basepython = python3.4 commands = sphinx-build -W -b html docs docs/_build From 149656c2c9f6375ec2fd0a2b4c6ad8592a5c5d81 Mon Sep 17 00:00:00 2001 From: Percy Perez Date: Wed, 23 Mar 2016 13:05:57 -0700 Subject: [PATCH 44/87] Update README and CHANGELOG --- CHANGELOG.rst | 6 ++++++ README.rst | 2 +- setup.py | 2 -- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9724eb7..9d145f9 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,9 @@ +0.2 (2016-03-23) +================ + +* Drop OAuth1 support. See .. _OAuth1-deprecated: https://dev.fitbit.com/docs/oauth2/#oauth-1-0a-deprecated +* Drop py26 and py32 support + 0.1.3 (2015-02-04) ================== diff --git a/README.rst b/README.rst index ff23090..b57101d 100644 --- a/README.rst +++ b/README.rst @@ -18,7 +18,7 @@ For documentation: `http://python-fitbit.readthedocs.org/ Date: Wed, 23 Mar 2016 13:23:13 -0700 Subject: [PATCH 45/87] Rename client_key to client_id which better matches fitbit docs --- CHANGELOG.rst | 2 +- fitbit/api.py | 4 ++-- fitbit_tests/test_auth.py | 2 +- fitbit_tests/test_exceptions.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9d145f9..49b5c70 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,7 +1,7 @@ 0.2 (2016-03-23) ================ -* Drop OAuth1 support. See .. _OAuth1-deprecated: https://dev.fitbit.com/docs/oauth2/#oauth-1-0a-deprecated +* Drop OAuth1 support. See `OAuth1 deprecated `_ * Drop py26 and py32 support 0.1.3 (2015-02-04) diff --git a/fitbit/api.py b/fitbit/api.py index dd24f35..df612e3 100644 --- a/fitbit/api.py +++ b/fitbit/api.py @@ -198,12 +198,12 @@ class Fitbit(object): 'frequent', ] - def __init__(self, client_key, client_secret, system=US, **kwargs): + def __init__(self, client_id, client_secret, system=US, **kwargs): """ Fitbit(, , access_token=, refresh_token=) """ self.system = system - self.client = FitbitOauth2Client(client_key, client_secret, **kwargs) + self.client = FitbitOauth2Client(client_id, client_secret, **kwargs) # All of these use the same patterns, define the method for accessing # creating and deleting records once, and use curry to make individual diff --git a/fitbit_tests/test_auth.py b/fitbit_tests/test_auth.py index dfc7271..6785ca7 100644 --- a/fitbit_tests/test_auth.py +++ b/fitbit_tests/test_auth.py @@ -11,7 +11,7 @@ class Auth2Test(TestCase): 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', 'scope': ['fake_scope1'] diff --git a/fitbit_tests/test_exceptions.py b/fitbit_tests/test_exceptions.py index 425727c..f656445 100644 --- a/fitbit_tests/test_exceptions.py +++ b/fitbit_tests/test_exceptions.py @@ -11,7 +11,7 @@ class ExceptionTest(unittest.TestCase): Tests that certain response codes raise certain exceptions """ client_kwargs = { - "client_key": "", + "client_id": "", "client_secret": "", "access_token": None, "refresh_token": None From 167ca2328c213a86872407f150300dde8836927b Mon Sep 17 00:00:00 2001 From: Percy Perez Date: Mon, 28 Mar 2016 15:20:01 -0700 Subject: [PATCH 46/87] Update requirements --- CHANGELOG.rst | 4 ++++ fitbit/__init__.py | 4 ++-- requirements/base.txt | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 49b5c70..7c0310b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,7 @@ +0.2.1 (2016-03-28) +================== +* Update requirements to use requests-oauthlib>=0.6.1 + 0.2 (2016-03-23) ================ diff --git a/fitbit/__init__.py b/fitbit/__init__.py index aa8cf36..216f743 100644 --- a/fitbit/__init__.py +++ b/fitbit/__init__.py @@ -17,8 +17,8 @@ __copyright__ = 'Copyright 2012-2015 ORCAS' __license__ = 'Apache 2.0' -__version__ = '0.2' -__release__ = '0.2' +__version__ = '0.2.1' +__release__ = '0.2.1' # Module namespace. diff --git a/requirements/base.txt b/requirements/base.txt index faab5be..90630aa 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,2 +1,2 @@ python-dateutil>=1.5,<2.5 -requests-oauthlib>=0.4,<1.1 +requests-oauthlib>=0.6.1,<1.1 From e3bb24c2acba1fb432f6ddb08c40ff5b382dab36 Mon Sep 17 00:00:00 2001 From: Xuefan Zhang Date: Tue, 29 Mar 2016 12:43:13 -0700 Subject: [PATCH 47/87] change condition for refresh_token() according to expired_token response. --- fitbit/api.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/fitbit/api.py b/fitbit/api.py index df612e3..1d6f96b 100644 --- a/fitbit/api.py +++ b/fitbit/api.py @@ -79,9 +79,8 @@ def make_request(self, url, data={}, method=None, **kwargs): 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): + if(d['errors'][0]['errorType'] == 'expired_token' and + d['errors'][0]['message'].find('Access token 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) From b2d00de05f4c8ab53668a13fb8ef39a896da0bff Mon Sep 17 00:00:00 2001 From: Xuefan Zhang Date: Tue, 29 Mar 2016 14:25:31 -0700 Subject: [PATCH 48/87] fix refresh token MissingAccessToken due to missing authorization header --- fitbit/api.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/fitbit/api.py b/fitbit/api.py index 1d6f96b..75c44c2 100644 --- a/fitbit/api.py +++ b/fitbit/api.py @@ -81,9 +81,9 @@ def make_request(self, url, data={}, method=None, **kwargs): try: if(d['errors'][0]['errorType'] == 'expired_token' and d['errors'][0]['message'].find('Access token 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) + 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 @@ -158,16 +158,12 @@ def refresh_token(self): obtained in step 2. the token is internally saved """ - - unenc_str = (self.client_id + ':' + self.client_secret).encode('utf8') - headers = { - 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', - 'Authorization': b'Basic ' + base64.b64encode(unenc_str) - } self.token = self.oauth.refresh_token( self.refresh_token_url, refresh_token=self.token['refresh_token'], - headers=headers) + auth=requests.auth.HTTPBasicAuth(self.client_id, self.client_secret) + ) + return self.token From 48169f8bc6ed74f89015ae457449fc8e7449293c Mon Sep 17 00:00:00 2001 From: Xuefan Zhang Date: Tue, 29 Mar 2016 21:31:25 -0700 Subject: [PATCH 49/87] catch HTTPUnauthorized instead of TokenExpiredError. Might need to parse error if this works --- fitbit/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fitbit/api.py b/fitbit/api.py index 75c44c2..f245b0c 100644 --- a/fitbit/api.py +++ b/fitbit/api.py @@ -68,7 +68,7 @@ def make_request(self, url, data={}, method=None, **kwargs): 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: + except HTTPUnauthorized 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) From 691b0be28b0ce1b4e72539bb0de52fb37a0b8096 Mon Sep 17 00:00:00 2001 From: Percy Perez Date: Wed, 30 Mar 2016 16:42:27 -0700 Subject: [PATCH 50/87] [#105509372] Fix tests and upgrade release version --- CHANGELOG.rst | 4 ++++ fitbit/__init__.py | 4 ++-- fitbit/api.py | 2 -- fitbit_tests/test_auth.py | 36 +++++++++++++++++++++++------------- 4 files changed, 29 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 7c0310b..c234bb1 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,7 @@ +0.2.2 (2016-03-30) +================== +* Refresh token bugfixes + 0.2.1 (2016-03-28) ================== * Update requirements to use requests-oauthlib>=0.6.1 diff --git a/fitbit/__init__.py b/fitbit/__init__.py index 216f743..5cdc066 100644 --- a/fitbit/__init__.py +++ b/fitbit/__init__.py @@ -17,8 +17,8 @@ __copyright__ = 'Copyright 2012-2015 ORCAS' __license__ = 'Apache 2.0' -__version__ = '0.2.1' -__release__ = '0.2.1' +__version__ = '0.2.2' +__release__ = '0.2.2' # Module namespace. diff --git a/fitbit/api.py b/fitbit/api.py index f245b0c..7a7a20d 100644 --- a/fitbit/api.py +++ b/fitbit/api.py @@ -1,5 +1,4 @@ # -*- coding: utf-8 -*- -import base64 import datetime import json import requests @@ -11,7 +10,6 @@ from urllib import urlencode from requests_oauthlib import OAuth2, OAuth2Session -from oauthlib.oauth2 import TokenExpiredError from fitbit.exceptions import (BadResponse, DeleteError, HTTPBadRequest, HTTPUnauthorized, HTTPForbidden, HTTPServerError, HTTPConflict, HTTPNotFound, diff --git a/fitbit_tests/test_auth.py b/fitbit_tests/test_auth.py index 6785ca7..be1de74 100644 --- a/fitbit_tests/test_auth.py +++ b/fitbit_tests/test_auth.py @@ -1,14 +1,15 @@ from unittest import TestCase from fitbit import Fitbit, FitbitOauth2Client +from fitbit.exceptions import HTTPUnauthorized import mock from requests_oauthlib import OAuth2Session -from oauthlib.oauth2 import TokenExpiredError 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_id': 'fake_id', @@ -60,8 +61,8 @@ def test_refresh_token(self): self.assertEqual("fake_return_refresh_token", retval['refresh_token']) def test_auto_refresh_token_exception(self): - # test of auto_refresh with tokenExpired exception - # 1. first call to _request causes a TokenExpired + """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 @@ -70,7 +71,10 @@ def test_auto_refresh_token_exception(self): fb = Fitbit(**kwargs) with mock.patch.object(FitbitOauth2Client, '_request') as r: - r.side_effect = [TokenExpiredError, fake_response(200, 'correct_response')] + r.side_effect = [ + HTTPUnauthorized(fake_response(401, b'correct_response')), + fake_response(200, 'correct_response') + ] with mock.patch.object(OAuth2Session, 'refresh_token') as rt: rt.return_value = { 'access_token': 'fake_return_access_token', @@ -78,13 +82,15 @@ def test_auto_refresh_token_exception(self): } 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( + "fake_return_access_token", fb.client.token['access_token']) + self.assertEqual( + "fake_return_refresh_token", fb.client.token['refresh_token']) self.assertEqual(1, rt.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 + def test_auto_refresh_token_non_exception(self): + """Test of auto_refersh when the exception doesn't fire""" # 1. first call to _request causes a 401 expired token response # 2. the token_refresh call is faked # 3. the second call to _request returns a valid value @@ -94,8 +100,10 @@ def test_auto_refresh_token_nonException(self): 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')] + r.side_effect = [ + fake_response(401, b'{"errors": [{"message": "Access token expired: some_token_goes_here", "errorType": "expired_token", "fieldName": "access_token"}]}'), + fake_response(200, 'correct_response') + ] with mock.patch.object(OAuth2Session, 'refresh_token') as rt: rt.return_value = { 'access_token': 'fake_return_access_token', @@ -103,8 +111,10 @@ def test_auto_refresh_token_nonException(self): } 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( + "fake_return_access_token", fb.client.token['access_token']) + self.assertEqual( + "fake_return_refresh_token", fb.client.token['refresh_token']) self.assertEqual(1, rt.call_count) self.assertEqual(2, r.call_count) From d81e70bd193c015bcb8facbe57c8d727d50f31e5 Mon Sep 17 00:00:00 2001 From: Ben Bodenmiller Date: Fri, 15 Jul 2016 17:17:53 -0400 Subject: [PATCH 51/87] Delete TODO --- TODO | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 TODO diff --git a/TODO b/TODO deleted file mode 100644 index cc97776..0000000 --- a/TODO +++ /dev/null @@ -1,6 +0,0 @@ -TODO - -* Public calls only based on consumer_key, (should work, untested) -* Change Units -* Docs -* Tests From 954017c9ae5ab62a2489e097b18ad8f4f33ea08c Mon Sep 17 00:00:00 2001 From: Marcelo Moreira de Mello Date: Sun, 28 Aug 2016 19:54:39 -0400 Subject: [PATCH 52/87] Fixes when oauth tokens expired and fetches a new token. Thanks to Michel Shim @shimeez --- fitbit/api.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/fitbit/api.py b/fitbit/api.py index 7a7a20d..d3f8bd5 100644 --- a/fitbit/api.py +++ b/fitbit/api.py @@ -10,6 +10,7 @@ from urllib import urlencode from requests_oauthlib import OAuth2, OAuth2Session +from oauthlib.oauth2.rfc6749.errors import TokenExpiredError from fitbit.exceptions import (BadResponse, DeleteError, HTTPBadRequest, HTTPUnauthorized, HTTPForbidden, HTTPServerError, HTTPConflict, HTTPNotFound, @@ -66,7 +67,7 @@ def make_request(self, url, data={}, method=None, **kwargs): try: auth = OAuth2(client_id=self.client_id, token=self.token) response = self._request(method, url, data=data, auth=auth, **kwargs) - except HTTPUnauthorized as e: + except (HTTPUnauthorized, 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) From 06840a2b7e2d78aef976f0bc7d87d4b4dea67ad4 Mon Sep 17 00:00:00 2001 From: Marcelo Moreira de Mello Date: Tue, 6 Sep 2016 12:59:37 -0400 Subject: [PATCH 53/87] Upgrade release version 0.2.3 --- CHANGELOG.rst | 4 ++++ fitbit/__init__.py | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index c234bb1..d5b1fa8 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,7 @@ +0.2.3 (2016-07-06) +================== +* Refresh token when it expires + 0.2.2 (2016-03-30) ================== * Refresh token bugfixes diff --git a/fitbit/__init__.py b/fitbit/__init__.py index 5cdc066..d2b77ca 100644 --- a/fitbit/__init__.py +++ b/fitbit/__init__.py @@ -17,8 +17,8 @@ __copyright__ = 'Copyright 2012-2015 ORCAS' __license__ = 'Apache 2.0' -__version__ = '0.2.2' -__release__ = '0.2.2' +__version__ = '0.2.3' +__release__ = '0.2.3' # Module namespace. From 09373a3390321f20016d2d5c3bbd5f5aca4eaa26 Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Mon, 7 Nov 2016 20:40:04 -0800 Subject: [PATCH 54/87] call a hook when tokens get refreshed --- fitbit/api.py | 6 +++++- fitbit_tests/test_auth.py | 6 ++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/fitbit/api.py b/fitbit/api.py index d3f8bd5..1984135 100644 --- a/fitbit/api.py +++ b/fitbit/api.py @@ -29,7 +29,7 @@ class FitbitOauth2Client(object): refresh_token_url = request_token_url def __init__(self, client_id, client_secret, - access_token=None, refresh_token=None, + access_token=None, refresh_token=None, refresh_cb=None, *args, **kwargs): """ Create a FitbitOauth2Client object. Specify the first 7 parameters if @@ -47,6 +47,7 @@ def __init__(self, client_id, client_secret, 'access_token': access_token, 'refresh_token': refresh_token } + self.refresh_cb = refresh_cb self.oauth = OAuth2Session(client_id) def _request(self, method, url, **kwargs): @@ -163,6 +164,9 @@ def refresh_token(self): auth=requests.auth.HTTPBasicAuth(self.client_id, self.client_secret) ) + if self.refresh_cb: + self.refresh_cb(self.token) + return self.token diff --git a/fitbit_tests/test_auth.py b/fitbit_tests/test_auth.py index be1de74..c7395d2 100644 --- a/fitbit_tests/test_auth.py +++ b/fitbit_tests/test_auth.py @@ -65,9 +65,11 @@ def test_auto_refresh_token_exception(self): # 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 + refresh_cb = mock.MagicMock() kwargs = self.client_kwargs kwargs['access_token'] = 'fake_access_token' kwargs['refresh_token'] = 'fake_refresh_token' + kwargs['refresh_cb'] = refresh_cb fb = Fitbit(**kwargs) with mock.patch.object(FitbitOauth2Client, '_request') as r: @@ -88,15 +90,18 @@ def test_auto_refresh_token_exception(self): "fake_return_refresh_token", fb.client.token['refresh_token']) self.assertEqual(1, rt.call_count) self.assertEqual(2, r.call_count) + refresh_cb.assert_called_once_with(rt.return_value) def test_auto_refresh_token_non_exception(self): """Test of auto_refersh when the exception doesn't fire""" # 1. first call to _request causes a 401 expired token response # 2. the token_refresh call is faked # 3. the second call to _request returns a valid value + refresh_cb = mock.MagicMock() kwargs = self.client_kwargs kwargs['access_token'] = 'fake_access_token' kwargs['refresh_token'] = 'fake_refresh_token' + kwargs['refresh_cb'] = refresh_cb fb = Fitbit(**kwargs) with mock.patch.object(FitbitOauth2Client, '_request') as r: @@ -117,6 +122,7 @@ def test_auto_refresh_token_non_exception(self): "fake_return_refresh_token", fb.client.token['refresh_token']) self.assertEqual(1, rt.call_count) self.assertEqual(2, r.call_count) + refresh_cb.assert_called_once_with(rt.return_value) class fake_response(object): From 2917926e30428a16c4209e548ba0244938751dd5 Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Thu, 10 Nov 2016 14:40:12 -0800 Subject: [PATCH 55/87] version 0.2.4 --- CHANGELOG.rst | 4 ++++ fitbit/__init__.py | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d5b1fa8..a7be7ab 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,7 @@ +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 diff --git a/fitbit/__init__.py b/fitbit/__init__.py index d2b77ca..be97389 100644 --- a/fitbit/__init__.py +++ b/fitbit/__init__.py @@ -17,8 +17,8 @@ __copyright__ = 'Copyright 2012-2015 ORCAS' __license__ = 'Apache 2.0' -__version__ = '0.2.3' -__release__ = '0.2.3' +__version__ = '0.2.4' +__release__ = '0.2.4' # Module namespace. From b476d0ae0eb3826629c25429da4e573762df4cc8 Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Fri, 11 Nov 2016 22:06:04 -0800 Subject: [PATCH 56/87] remove the ceiling of the dateutil package version --- requirements/base.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/base.txt b/requirements/base.txt index 90630aa..a9064b2 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,2 +1,2 @@ -python-dateutil>=1.5,<2.5 +python-dateutil>=1.5 requests-oauthlib>=0.6.1,<1.1 From 57a1f542cb461ee487d7ed5d4e3ec04157444236 Mon Sep 17 00:00:00 2001 From: Alan Brammer Date: Tue, 13 Dec 2016 22:06:01 -0500 Subject: [PATCH 57/87] changed old wiki.fitbit url links to new dev.fitbit links --- fitbit/api.py | 113 +++++++++++++++++++-------------------- fitbit_tests/test_api.py | 16 +++--- 2 files changed, 62 insertions(+), 67 deletions(-) diff --git a/fitbit/api.py b/fitbit/api.py index 1984135..bab5f10 100644 --- a/fitbit/api.py +++ b/fitbit/api.py @@ -60,7 +60,7 @@ def make_request(self, url, data={}, 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' @@ -114,7 +114,7 @@ def authorize_token_url(self, scope=None, redirect_uri=None, **kwargs): - 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 + for more info see https://dev.fitbit.com/docs/oauth2/ """ # the scope parameter is caussing some issues when refreshing tokens @@ -255,7 +255,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) @@ -269,7 +269,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) @@ -307,7 +307,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: @@ -368,8 +368,8 @@ def body_fat_goal(self, fat=None): """ Implements the following APIs - * https://wiki.fitbit.com/display/API/API-Get-Body-Fat - * https://wiki.fitbit.com/display/API/API-Update-Fat-Goal + * https://dev.fitbit.com/docs/body/#body-fat + * https://dev.fitbit.com/docs/body/#log-body-fat Pass no arguments to get the body fat goal. Pass a ``fat`` argument to update the body fat goal. @@ -383,8 +383,7 @@ def body_weight_goal(self, start_date=None, start_weight=None, weight=None): """ Implements the following APIs - * https://wiki.fitbit.com/display/API/API-Get-Body-Weight-Goal - * https://wiki.fitbit.com/display/API/API-Update-Weight-Goal + https://dev.fitbit.com/docs/body/#goals Pass no arguments to get the body weight goal. Pass ``start_date``, ``start_weight`` and optionally ``weight`` to set the weight goal. @@ -409,8 +408,7 @@ def activities_daily_goal(self, calories_out=None, active_minutes=None, """ Implements the following APIs - https://wiki.fitbit.com/display/API/API-Get-Activity-Daily-Goals - https://wiki.fitbit.com/display/API/API-Update-Activity-Daily-Goals + https://dev.fitbit.com/docs/activity/#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 @@ -436,8 +434,7 @@ def activities_weekly_goal(self, distance=None, floors=None, steps=None): """ Implements the following APIs - https://wiki.fitbit.com/display/API/API-Get-Activity-Weekly-Goals - https://wiki.fitbit.com/display/API/API-Update-Activity-Weekly-Goals + https://dev.fitbit.com/docs/activity/#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 @@ -456,8 +453,7 @@ def food_goal(self, calories=None, intensity=None, personalized=None): """ Implements the following APIs - https://wiki.fitbit.com/display/API/API-Get-Food-Goals - https://wiki.fitbit.com/display/API/API-Update-Food-Goals + https://dev.fitbit.com/docs/food-logging/#get-food-goals Pass no arguments to get the food goal. Pass at least ``calories`` or ``intensity`` and optionally ``personalized`` to update the food goal. @@ -477,8 +473,7 @@ def water_goal(self, target=None): """ Implements the following APIs - https://wiki.fitbit.com/display/API/API-Get-Water-Goal - https://wiki.fitbit.com/display/API/API-Update-Water-Goal + https://dev.fitbit.com/docs/food-logging/#get-water-goal Pass no arguments to get the water goal. Pass ``target`` to update it. @@ -498,7 +493,7 @@ def time_series(self, resource, user_id=None, base_date='today', 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 """ if period and end_date: raise TypeError("Either end_date or period can be specified, not both") @@ -523,10 +518,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 @@ -537,7 +532,7 @@ def intraday_time_series(self, resource, base_date='today', detail_level='1min', """ Per - https://wiki.fitbit.com/display/API/API-Get-Intraday-Time-Series + 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. """ @@ -565,10 +560,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:: @@ -599,9 +594,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), @@ -611,7 +606,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(), @@ -621,14 +616,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) 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(), @@ -638,7 +633,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(), @@ -648,7 +643,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(), @@ -658,28 +653,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(), @@ -691,7 +686,7 @@ 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( @@ -724,7 +719,7 @@ def add_alarm(self, device_id, alarm_time, week_days, recurring=False, 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. @@ -759,7 +754,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(), @@ -770,7 +765,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( @@ -783,7 +778,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 = { @@ -796,14 +791,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(), @@ -813,7 +808,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(), @@ -823,7 +818,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(), @@ -833,14 +828,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. @@ -851,7 +846,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. @@ -888,14 +883,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'") @@ -907,7 +902,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) @@ -915,20 +910,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(), @@ -951,7 +946,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) @@ -959,7 +954,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} @@ -976,7 +971,7 @@ 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(), diff --git a/fitbit_tests/test_api.py b/fitbit_tests/test_api.py index 651a189..b3f33cf 100644 --- a/fitbit_tests/test_api.py +++ b/fitbit_tests/test_api.py @@ -210,12 +210,12 @@ def test_delete_foods_log_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 @@ -230,7 +230,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 @@ -441,7 +441,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 @@ -483,7 +483,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 @@ -608,7 +608,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): @@ -637,7 +637,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, @@ -652,7 +652,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 From c583c7b4d5e699893b17b707ffe53fae55c2ab72 Mon Sep 17 00:00:00 2001 From: Alan Brammer Date: Thu, 15 Dec 2016 14:08:43 -0500 Subject: [PATCH 58/87] Minor updates based on feedback --- fitbit/api.py | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/fitbit/api.py b/fitbit/api.py index bab5f10..b50da59 100644 --- a/fitbit/api.py +++ b/fitbit/api.py @@ -368,8 +368,8 @@ def body_fat_goal(self, fat=None): """ Implements the following APIs - * https://dev.fitbit.com/docs/body/#body-fat - * https://dev.fitbit.com/docs/body/#log-body-fat + * 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. @@ -383,8 +383,9 @@ def body_weight_goal(self, start_date=None, start_weight=None, weight=None): """ Implements the following APIs - https://dev.fitbit.com/docs/body/#goals - + * 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. @@ -406,9 +407,10 @@ def body_weight_goal(self, start_date=None, start_weight=None, weight=None): def activities_daily_goal(self, calories_out=None, active_minutes=None, floors=None, distance=None, steps=None): """ - Implements the following APIs + Implements the following APIs for period equal to daily - https://dev.fitbit.com/docs/activity/#activity-goals + 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 @@ -432,9 +434,10 @@ def activities_daily_goal(self, calories_out=None, active_minutes=None, def activities_weekly_goal(self, distance=None, floors=None, steps=None): """ - Implements the following APIs + Implements the following APIs for period equal to weekly - https://dev.fitbit.com/docs/activity/#activity-goals + 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 @@ -453,7 +456,8 @@ 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/#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. @@ -474,6 +478,7 @@ 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. @@ -486,7 +491,7 @@ def water_goal(self, target=None): 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. @@ -494,6 +499,10 @@ def time_series(self, resource, user_id=None, base_date='today', and a 1d period. 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") From 62a94426e87c4f257a6dbe5f4690103311b4cd14 Mon Sep 17 00:00:00 2001 From: Arne Welzel Date: Tue, 20 Dec 2016 09:51:49 -0800 Subject: [PATCH 59/87] api: support timeout kwarg to be handed down to requests --- fitbit/api.py | 11 ++++++++-- fitbit/exceptions.py | 7 ++++++ fitbit_tests/test_api.py | 46 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/fitbit/api.py b/fitbit/api.py index 1984135..7e41509 100644 --- a/fitbit/api.py +++ b/fitbit/api.py @@ -14,7 +14,7 @@ from fitbit.exceptions import (BadResponse, DeleteError, HTTPBadRequest, HTTPUnauthorized, HTTPForbidden, HTTPServerError, HTTPConflict, HTTPNotFound, - HTTPTooManyRequests) + HTTPTooManyRequests, Timeout) from fitbit.utils import curry @@ -49,12 +49,19 @@ def __init__(self, client_id, client_secret, } self.refresh_cb = refresh_cb self.oauth = OAuth2Session(client_id) + 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: + return self.session.request(method, url, **kwargs) + except requests.Timeout as e: + raise Timeout(*e.args) def make_request(self, url, data={}, method=None, **kwargs): """ diff --git a/fitbit/exceptions.py b/fitbit/exceptions.py index d6249ea..8eb774a 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: diff --git a/fitbit_tests/test_api.py b/fitbit_tests/test_api.py index 651a189..56fae78 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) @@ -24,6 +25,49 @@ 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 From 24cd6c2ab816f11acb3e095581a86b6b11a0502c Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Tue, 3 Jan 2017 15:32:06 -0800 Subject: [PATCH 60/87] refactor some exception processing --- fitbit/api.py | 42 +++++++++++++----------------------------- fitbit/exceptions.py | 19 +++++++++++++++++++ 2 files changed, 32 insertions(+), 29 deletions(-) diff --git a/fitbit/api.py b/fitbit/api.py index 364a597..634234e 100644 --- a/fitbit/api.py +++ b/fitbit/api.py @@ -9,13 +9,12 @@ # Python 2.x from urllib import urlencode +from requests.auth import HTTPBasicAuth from requests_oauthlib import OAuth2, OAuth2Session from oauthlib.oauth2.rfc6749.errors import TokenExpiredError -from fitbit.exceptions import (BadResponse, DeleteError, HTTPBadRequest, - HTTPUnauthorized, HTTPForbidden, - HTTPServerError, HTTPConflict, HTTPNotFound, - HTTPTooManyRequests, Timeout) -from fitbit.utils import curry + +from . import exceptions +from .utils import curry class FitbitOauth2Client(object): @@ -61,7 +60,7 @@ def _request(self, method, url, **kwargs): try: return self.session.request(method, url, **kwargs) except requests.Timeout as e: - raise Timeout(*e.args) + raise exceptions.Timeout(*e.args) def make_request(self, url, data={}, method=None, **kwargs): """ @@ -75,7 +74,7 @@ def make_request(self, url, data={}, method=None, **kwargs): try: auth = OAuth2(client_id=self.client_id, token=self.token) response = self._request(method, url, data=data, auth=auth, **kwargs) - except (HTTPUnauthorized, TokenExpiredError) as e: + except (exceptions.HTTPUnauthorized, 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) @@ -94,23 +93,8 @@ def make_request(self, url, data={}, method=None, **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) + exceptions.detect_and_raise_error(response) + return response def authorize_token_url(self, scope=None, redirect_uri=None, **kwargs): @@ -168,7 +152,7 @@ def refresh_token(self): self.token = self.oauth.refresh_token( self.refresh_token_url, refresh_token=self.token['refresh_token'], - auth=requests.auth.HTTPBasicAuth(self.client_id, self.client_secret) + auth=HTTPBasicAuth(self.client_id, self.client_secret) ) if self.refresh_cb: @@ -244,11 +228,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 @@ -390,9 +374,9 @@ 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/#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. diff --git a/fitbit/exceptions.py b/fitbit/exceptions.py index 8eb774a..677958a 100644 --- a/fitbit/exceptions.py +++ b/fitbit/exceptions.py @@ -75,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) From 9f8b2cf77fd03ebead5e94a2db95a574e41100ee Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Wed, 4 Jan 2017 08:08:35 -0800 Subject: [PATCH 61/87] ignore .eggs and htmlcov --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) 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 From 0da2b3862feafe916afa5f247b894b479f6afeef Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Wed, 4 Jan 2017 14:00:07 -0800 Subject: [PATCH 62/87] improve fitbit request error handling, other cleanup - Register compliance hooks so we stop erroneously getting MissingTokenError - Require redirect_uri - Use request-oauthlib auto refresh mechanism, using 'expires_at' - Let request-oauthlib do more of the work in general - Reconfigure some tests to engage the request-oauthlib code --- fitbit/api.py | 122 ++++++++++------------ fitbit/compliance.py | 26 +++++ fitbit_tests/test_auth.py | 175 +++++++++++++++++++------------- fitbit_tests/test_exceptions.py | 15 ++- gather_keys_oauth2.py | 20 ++-- requirements/base.txt | 2 +- requirements/test.txt | 4 +- 7 files changed, 217 insertions(+), 147 deletions(-) create mode 100644 fitbit/compliance.py diff --git a/fitbit/api.py b/fitbit/api.py index 634234e..9da83a4 100644 --- a/fitbit/api.py +++ b/fitbit/api.py @@ -14,6 +14,7 @@ from oauthlib.oauth2.rfc6749.errors import TokenExpiredError from . import exceptions +from .compliance import fitbit_compliance_fix from .utils import curry @@ -27,9 +28,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, refresh_cb=None, - *args, **kwargs): + def __init__(self, client_id, client_secret, access_token=None, + refresh_token=None, expires_at=None, refresh_cb=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 @@ -39,15 +40,21 @@ 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 - self.token = { - 'access_token': access_token, - 'refresh_token': refresh_token - } - self.refresh_cb = refresh_cb - 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, + )) self.timeout = kwargs.get("timeout", None) def _request(self, method, url, **kwargs): @@ -58,7 +65,17 @@ def _request(self, method, url, **kwargs): kwargs['timeout'] = self.timeout try: - return self.session.request(method, url, **kwargs) + response = self.session.request(method, url, **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) @@ -68,97 +85,68 @@ def make_request(self, url, data={}, method=None, **kwargs): 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 (exceptions.HTTPUnauthorized, 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'] == 'expired_token' and - d['errors'][0]['message'].find('Access token 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 + method = method or ('POST' if data else 'GET') + response = self._request(method, url, data=data, **kwargs) exceptions.detect_and_raise_error(response) return response - def authorize_token_url(self, scope=None, redirect_uri=None, **kwargs): + def authorize_token_url(self, redirect_uri, scope=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. - 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 + - redirect_uri: url to which the reponse will posted. required for more info see https://dev.fitbit.com/docs/oauth2/ """ - # 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" - ] - - if redirect_uri: - self.oauth.redirect_uri = redirect_uri + self.session.scope = scope or [ + "activity", + "nutrition", + "heartrate", + "location", + "nutrition", + "profile", + "settings", + "sleep", + "social", + "weight", + ] + 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): """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.session.fetch_token( self.access_token_url, username=self.client_id, password=self.client_secret, code=code) - return self.token + return self.session.token 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 """ - self.token = self.oauth.refresh_token( + self.session.refresh_token( self.refresh_token_url, - refresh_token=self.token['refresh_token'], auth=HTTPBasicAuth(self.client_id, self.client_secret) ) - if self.refresh_cb: - self.refresh_cb(self.token) + if self.session.token_updater: + self.session.token_updater(self.session.token) - return self.token + return self.session.token class Fitbit(object): 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_tests/test_auth.py b/fitbit_tests/test_auth.py index c7395d2..fdf30dd 100644 --- a/fitbit_tests/test_auth.py +++ b/fitbit_tests/test_auth.py @@ -1,8 +1,16 @@ +import copy +import json +import mock +import requests_mock + +from datetime import datetime +from freezegun import freeze_time +from oauthlib.oauth2.rfc6749.errors import InvalidGrantError +from requests_oauthlib import OAuth2Session from unittest import TestCase + from fitbit import Fitbit, FitbitOauth2Client from fitbit.exceptions import HTTPUnauthorized -import mock -from requests_oauthlib import OAuth2Session class Auth2Test(TestCase): @@ -14,115 +22,144 @@ class Auth2Test(TestCase): client_kwargs = { 'client_id': 'fake_id', 'client_secret': 'fake_secret', - 'callback_uri': 'fake_callback_url', + '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]) + retval = fb.client.authorize_token_url('http://127.0.0.1:8080') + 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']) + 'http://127.0.0.1:8080', 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' fb = Fitbit(**kwargs) - with mock.patch.object(OAuth2Session, 'refresh_token') as rt: - rt.return_value = { + 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_refresh with Unauthorized exception""" + @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 refresh_cb = mock.MagicMock() - kwargs = self.client_kwargs - kwargs['access_token'] = 'fake_access_token' - kwargs['refresh_token'] = 'fake_refresh_token' - kwargs['refresh_cb'] = refresh_cb + 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 = [ - HTTPUnauthorized(fake_response(401, b'correct_response')), - fake_response(200, 'correct_response') - ] - with mock.patch.object(OAuth2Session, 'refresh_token') as rt: - rt.return_value = { - 'access_token': 'fake_return_access_token', - '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, rt.call_count) - self.assertEqual(2, r.call_count) - refresh_cb.assert_called_once_with(rt.return_value) - - def test_auto_refresh_token_non_exception(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(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 refresh_cb = mock.MagicMock() - kwargs = self.client_kwargs - kwargs['access_token'] = 'fake_access_token' - kwargs['refresh_token'] = 'fake_refresh_token' - kwargs['refresh_cb'] = refresh_cb + 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(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 expired: some_token_goes_here", "errorType": "expired_token", "fieldName": "access_token"}]}'), - fake_response(200, 'correct_response') - ] - with mock.patch.object(OAuth2Session, 'refresh_token') as rt: - rt.return_value = { - 'access_token': 'fake_return_access_token', - '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, rt.call_count) - self.assertEqual(2, r.call_count) - refresh_cb.assert_called_once_with(rt.return_value) + 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): diff --git a/fitbit_tests/test_exceptions.py b/fitbit_tests/test_exceptions.py index f656445..d43b656 100644 --- a/fitbit_tests/test_exceptions.py +++ b/fitbit_tests/test_exceptions.py @@ -1,4 +1,5 @@ import unittest +import json import mock import requests import sys @@ -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,6 +60,11 @@ 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): diff --git a/gather_keys_oauth2.py b/gather_keys_oauth2.py index 7188644..bda4aa3 100755 --- a/gather_keys_oauth2.py +++ b/gather_keys_oauth2.py @@ -7,9 +7,8 @@ import webbrowser 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: @@ -22,14 +21,15 @@ def __init__(self, client_id, client_secret,

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) 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(self.redirect_uri) # Open the web browser in a new thread for command-line browser support threading.Timer(1, webbrowser.open, args=(url,)).start() cherrypy.quickstart(self) @@ -43,7 +43,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 ' @@ -76,6 +76,10 @@ 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']) - print('REFRESH_TOKEN = %s' % server.oauth.token['refresh_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 a9064b2..1331f7b 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,2 +1,2 @@ python-dateutil>=1.5 -requests-oauthlib>=0.6.1,<1.1 +requests-oauthlib>=0.7 diff --git a/requirements/test.txt b/requirements/test.txt index d5c6230..711c52b 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -1,3 +1,5 @@ -mock>=1.0,<1.4 coverage>=3.7,<4.0 +freezegun>=0.3.8 +mock>=1.0 +requests-mock>=1.2.0 Sphinx>=1.2,<1.4 From cb80935b32bc53b28c4f4b663db44e2b830749e1 Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Wed, 4 Jan 2017 15:35:51 -0800 Subject: [PATCH 63/87] maintain backward-compatible API --- fitbit/api.py | 22 +++++++++++++++------- fitbit_tests/test_auth.py | 5 ++--- gather_keys_oauth2.py | 9 ++++++--- 3 files changed, 23 insertions(+), 13 deletions(-) diff --git a/fitbit/api.py b/fitbit/api.py index 9da83a4..b069b9d 100644 --- a/fitbit/api.py +++ b/fitbit/api.py @@ -29,8 +29,8 @@ class FitbitOauth2Client(object): refresh_token_url = request_token_url def __init__(self, client_id, client_secret, access_token=None, - refresh_token=None, expires_at=None, refresh_cb=None, *args, - **kwargs): + 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 @@ -54,6 +54,7 @@ def __init__(self, client_id, client_secret, access_token=None, auto_refresh_url=self.refresh_token_url, token_updater=refresh_cb, token=token, + redirect_uri=redirect_uri, )) self.timeout = kwargs.get("timeout", None) @@ -79,12 +80,13 @@ def _request(self, method, url, **kwargs): except requests.Timeout as e: raise exceptions.Timeout(*e.args) - def make_request(self, url, data={}, method=None, **kwargs): + def make_request(self, url, data=None, method=None, **kwargs): """ Builds and makes the OAuth2 Request, catches errors https://dev.fitbit.com/docs/oauth2/#authorization-errors """ + data = data or {} method = method or ('POST' if data else 'GET') response = self._request(method, url, data=data, **kwargs) @@ -92,13 +94,15 @@ def make_request(self, url, data={}, method=None, **kwargs): return response - def authorize_token_url(self, redirect_uri, scope=None, **kwargs): + 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. - scope: pemissions that that are being requested [default ask all] - - redirect_uri: url to which the reponse will posted. required + - redirect_uri: url to which the reponse 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/ """ @@ -114,17 +118,21 @@ def authorize_token_url(self, redirect_uri, scope=None, **kwargs): "social", "weight", ] - self.session.redirect_uri = redirect_uri + + if redirect_uri: + self.session.redirect_uri = redirect_uri return self.session.authorization_url(self.authorization_url, **kwargs) - def fetch_access_token(self, code): + def fetch_access_token(self, code, redirect_uri=None): """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 """ + if redirect_uri: + self.session.redirect_uri = redirect_uri self.session.fetch_token( self.access_token_url, username=self.client_id, diff --git a/fitbit_tests/test_auth.py b/fitbit_tests/test_auth.py index fdf30dd..d9e5e98 100644 --- a/fitbit_tests/test_auth.py +++ b/fitbit_tests/test_auth.py @@ -29,14 +29,13 @@ class Auth2Test(TestCase): 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('http://127.0.0.1:8080') + retval = fb.client.authorize_token_url() 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_scope(self): # authorize_token_url calls oauth and returns a URL fb = Fitbit(**self.client_kwargs) - retval = fb.client.authorize_token_url( - 'http://127.0.0.1:8080', scope=self.client_kwargs['scope']) + 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): diff --git a/gather_keys_oauth2.py b/gather_keys_oauth2.py index bda4aa3..a1eebd4 100755 --- a/gather_keys_oauth2.py +++ b/gather_keys_oauth2.py @@ -15,21 +15,24 @@ 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.fitbit = Fitbit(client_id, client_secret) + self.fitbit = Fitbit( + client_id, + client_secret, + 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.fitbit.client.authorize_token_url(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() cherrypy.quickstart(self) From de161061617e0745fed2b32d2a254d1e5acc80c6 Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Thu, 5 Jan 2017 07:19:12 -0800 Subject: [PATCH 64/87] add timeout kwarg to gather keys script --- gather_keys_oauth2.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/gather_keys_oauth2.py b/gather_keys_oauth2.py index a1eebd4..aade911 100755 --- a/gather_keys_oauth2.py +++ b/gather_keys_oauth2.py @@ -24,7 +24,8 @@ def __init__(self, client_id, client_secret, self.fitbit = Fitbit( client_id, client_secret, - redirect_uri=redirect_uri + redirect_uri=redirect_uri, + timeout=10, ) def browser_authorize(self): From c0b07d256679870e6285671a2285c4ba8e68ad84 Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Thu, 5 Jan 2017 08:22:50 -0800 Subject: [PATCH 65/87] don't rely on OAuth2Session internal API --- fitbit/api.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/fitbit/api.py b/fitbit/api.py index b069b9d..c085466 100644 --- a/fitbit/api.py +++ b/fitbit/api.py @@ -133,28 +133,26 @@ def fetch_access_token(self, code, redirect_uri=None): """ if redirect_uri: self.session.redirect_uri = redirect_uri - self.session.fetch_token( + return self.session.fetch_token( self.access_token_url, username=self.client_id, password=self.client_secret, code=code) - return self.session.token - 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 """ - self.session.refresh_token( + token = self.session.refresh_token( self.refresh_token_url, auth=HTTPBasicAuth(self.client_id, self.client_secret) ) if self.session.token_updater: - self.session.token_updater(self.session.token) + self.session.token_updater(token) - return self.session.token + return token class Fitbit(object): From 737c99a16ff6ba94f15b53a9135705ed84921a07 Mon Sep 17 00:00:00 2001 From: Jess Johnson Date: Mon, 9 Jan 2017 17:00:41 -0800 Subject: [PATCH 66/87] fitbit/api.py --- fitbit/api.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/fitbit/api.py b/fitbit/api.py index c085466..995194c 100644 --- a/fitbit/api.py +++ b/fitbit/api.py @@ -141,15 +141,15 @@ def fetch_access_token(self, code, redirect_uri=None): 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 + obtained in step 2. Only do the refresh if there is `token_updater(),` + which saves the token. """ - token = self.session.refresh_token( - self.refresh_token_url, - auth=HTTPBasicAuth(self.client_id, self.client_secret) - ) - 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 From cb0731e2bbccd378095c3d77ffb58bc99fba4e80 Mon Sep 17 00:00:00 2001 From: Jess Johnson Date: Tue, 10 Jan 2017 08:27:31 -0800 Subject: [PATCH 67/87] Fix token return. --- fitbit/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fitbit/api.py b/fitbit/api.py index 995194c..2f1faaa 100644 --- a/fitbit/api.py +++ b/fitbit/api.py @@ -144,8 +144,8 @@ def refresh_token(self): obtained in step 2. Only do the refresh if there is `token_updater(),` which saves the token. """ + token = None if self.session.token_updater: - token = self.session.refresh_token( self.refresh_token_url, auth=HTTPBasicAuth(self.client_id, self.client_secret) From 515be13b02a3a9aa8f6d69d7bfa1ce601f904950 Mon Sep 17 00:00:00 2001 From: Jess Johnson Date: Tue, 10 Jan 2017 09:05:24 -0800 Subject: [PATCH 68/87] PEP8; fix tests. --- fitbit/api.py | 5 ++--- fitbit_tests/test_auth.py | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/fitbit/api.py b/fitbit/api.py index 2f1faaa..109c9b8 100644 --- a/fitbit/api.py +++ b/fitbit/api.py @@ -10,8 +10,7 @@ from urllib import urlencode from requests.auth import HTTPBasicAuth -from requests_oauthlib import OAuth2, OAuth2Session -from oauthlib.oauth2.rfc6749.errors import TokenExpiredError +from requests_oauthlib import OAuth2Session from . import exceptions from .compliance import fitbit_compliance_fix @@ -144,7 +143,7 @@ def refresh_token(self): obtained in step 2. Only do the refresh if there is `token_updater(),` which saves the token. """ - token = None + token = {} if self.session.token_updater: token = self.session.refresh_token( self.refresh_token_url, diff --git a/fitbit_tests/test_auth.py b/fitbit_tests/test_auth.py index d9e5e98..8e07144 100644 --- a/fitbit_tests/test_auth.py +++ b/fitbit_tests/test_auth.py @@ -6,11 +6,9 @@ from datetime import datetime from freezegun import freeze_time from oauthlib.oauth2.rfc6749.errors import InvalidGrantError -from requests_oauthlib import OAuth2Session from unittest import TestCase -from fitbit import Fitbit, FitbitOauth2Client -from fitbit.exceptions import HTTPUnauthorized +from fitbit import Fitbit class Auth2Test(TestCase): @@ -56,6 +54,7 @@ def test_refresh_token(self): 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 requests_mock.mock() as m: m.post(fb.client.refresh_token_url, text=json.dumps({ From 7784180e9490921ac54bf1627c3c12c90a6bac6a Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Thu, 12 Jan 2017 11:51:32 -0800 Subject: [PATCH 69/87] add client_id/secret to all requests --- fitbit/api.py | 9 ++++++++- fitbit_tests/test_auth.py | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/fitbit/api.py b/fitbit/api.py index 109c9b8..b7e92e5 100644 --- a/fitbit/api.py +++ b/fitbit/api.py @@ -87,7 +87,14 @@ def make_request(self, url, data=None, method=None, **kwargs): """ data = data or {} method = method or ('POST' if data else 'GET') - response = self._request(method, url, data=data, **kwargs) + response = self._request( + method, + url, + data=data, + client_id=self.client_id, + client_secret=self.client_secret, + **kwargs + ) exceptions.detect_and_raise_error(response) diff --git a/fitbit_tests/test_auth.py b/fitbit_tests/test_auth.py index 8e07144..6bf7ab7 100644 --- a/fitbit_tests/test_auth.py +++ b/fitbit_tests/test_auth.py @@ -6,6 +6,7 @@ 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 from fitbit import Fitbit @@ -95,6 +96,15 @@ def test_auto_refresh_expires_at(self): } 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']) @@ -134,6 +144,15 @@ def test_auto_refresh_token_exception(self): } 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']) From 5f345ff819e3508a5765e47c26be7b5a4b634425 Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Tue, 24 Jan 2017 09:01:08 -0800 Subject: [PATCH 70/87] version 0.3.0 --- LICENSE | 2 +- fitbit/__init__.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) 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/fitbit/__init__.py b/fitbit/__init__.py index be97389..a19bb4a 100644 --- a/fitbit/__init__.py +++ b/fitbit/__init__.py @@ -3,7 +3,7 @@ Fitbit API Library ------------------ -:copyright: 2012-2015 ORCAS. +:copyright: 2012-2017 ORCAS. :license: BSD, see LICENSE for more details. """ @@ -14,11 +14,11 @@ __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.2.4' -__release__ = '0.2.4' +__version__ = '0.3.0' +__release__ = '0.3.0' # Module namespace. From 1e44d831ee4a9bbba61a402d4b36a9822f20d162 Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Tue, 24 Jan 2017 13:48:22 -0800 Subject: [PATCH 71/87] add change log for 0.3.0 --- CHANGELOG.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a7be7ab..db75618 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,8 @@ +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 From 63204a2e1494b564e27c782244b1e6ab081a3429 Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Tue, 24 Jan 2017 13:48:37 -0800 Subject: [PATCH 72/87] hide private methods, document curried methods --- docs/index.rst | 83 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/docs/index.rst b/docs/index.rst index d773a73..34963c8 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -40,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 ================== From 6a395c6895e6ab6c0c8fb900bd1ddfd9ba612591 Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Tue, 24 Jan 2017 14:16:57 -0800 Subject: [PATCH 73/87] document the finer practical points of usage --- fitbit/api.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/fitbit/api.py b/fitbit/api.py index b7e92e5..ca928c3 100644 --- a/fitbit/api.py +++ b/fitbit/api.py @@ -162,6 +162,27 @@ def refresh_token(self): 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' @@ -187,7 +208,9 @@ class Fitbit(object): 'frequent', ] - def __init__(self, client_id, client_secret, 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): """ Fitbit(, , access_token=, refresh_token=) """ From 3f57e1791e8c18bd0e8e6d683e110d4d99e0529e Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Tue, 24 Jan 2017 15:05:32 -0800 Subject: [PATCH 74/87] fix tests --- fitbit/api.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/fitbit/api.py b/fitbit/api.py index ca928c3..14c33dc 100644 --- a/fitbit/api.py +++ b/fitbit/api.py @@ -215,7 +215,16 @@ def __init__(self, client_id, client_secret, access_token=None, Fitbit(, , access_token=, refresh_token=) """ self.system = system - self.client = FitbitOauth2Client(client_id, 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 From 0e9caf5cc03eaf01c3cad6e79af54c7f26eeb39b Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Wed, 7 Jun 2017 07:58:10 -0700 Subject: [PATCH 75/87] add gitter badge --- README.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.rst b/README.rst index b57101d..90797ba 100644 --- a/README.rst +++ b/README.rst @@ -10,6 +10,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 From 5061f5adef79611a63db6e0f7a46c0d6dfbe280a Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Fri, 1 Sep 2017 20:57:00 +0100 Subject: [PATCH 76/87] correct spelling mistake --- fitbit/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fitbit/api.py b/fitbit/api.py index 14c33dc..ba9d037 100644 --- a/fitbit/api.py +++ b/fitbit/api.py @@ -106,7 +106,7 @@ def authorize_token_url(self, scope=None, redirect_uri=None, **kwargs): URL, open their browser to it, or tell them to copy the URL into their browser. - scope: pemissions that that are being requested [default ask all] - - redirect_uri: url to which the reponse will posted. required here + - 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/ From 5e8fae3a9761bd603a580be57eb0058197d87f17 Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Tue, 20 Feb 2018 15:12:26 -0700 Subject: [PATCH 77/87] upgrade travis python to 3.6 --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 9c50862..5cca0f6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,5 @@ language: python -python: 3.5 +python: 3.6 env: # Avoid testing pypy on travis until the following issue is fixed: # https://github.com/travis-ci/travis-ci/issues/4756 From 4faf9bf112e77f4cb5eba1489377f45b3cd73320 Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Tue, 20 Feb 2018 15:19:57 -0700 Subject: [PATCH 78/87] simplify test configuration, support python 3.6 and pypy3 --- .travis.yml | 21 +++++++++------------ setup.py | 1 + tox.ini | 27 ++++++--------------------- 3 files changed, 16 insertions(+), 33 deletions(-) diff --git a/.travis.yml b/.travis.yml index 5cca0f6..67d8e3a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,15 +1,12 @@ language: python -python: 3.6 -env: - # Avoid testing pypy on travis until the following issue is fixed: - # https://github.com/travis-ci/travis-ci/issues/4756 - #- TOX_ENV=pypy - - TOX_ENV=py35 - - TOX_ENV=py34 - - TOX_ENV=py33 - - TOX_ENV=py27 - - TOX_ENV=docs +python: + - pypy + - pypy3.3-5.2-alpha1 + - 2.7 + - 3.3 + - 3.4 + - 3.5 install: - - pip install coveralls tox -script: tox -e $TOX_ENV + - pip install coveralls tox-travis +script: tox after_success: coveralls diff --git a/setup.py b/setup.py index c17939a..f931edb 100644 --- a/setup.py +++ b/setup.py @@ -38,6 +38,7 @@ '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 279b114..e19ff25 100644 --- a/tox.ini +++ b/tox.ini @@ -1,25 +1,10 @@ [tox] -envlist = pypy,py35,py34,py33,py27,docs +envlist = pypy,pypy3,py36-docs,py36,py35,py34,py33,py27 [testenv] -commands = coverage run --source=fitbit setup.py test +changedir = + docs: {toxinidir}/docs +commands = + py{py,py3,36,35,34,33,27}: 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:py35] -basepython = python3.5 - -[testenv:py34] -basepython = python3.4 - -[testenv:py33] -basepython = python3.3 - -[testenv:py27] -basepython = python2.7 - -[testenv:docs] -basepython = python3.4 -commands = sphinx-build -W -b html docs docs/_build From 41a7419852054c57e300f10dbf69fd3ef095739b Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Tue, 20 Feb 2018 15:23:55 -0700 Subject: [PATCH 79/87] whoops, add python 3.6 to travis matrix --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 67d8e3a..219cc33 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,6 +6,7 @@ python: - 3.3 - 3.4 - 3.5 + - 3.6 install: - pip install coveralls tox-travis script: tox From 6755500a1bb15aef16b1ae09a4b0579e8910313c Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Tue, 20 Feb 2018 15:31:51 -0700 Subject: [PATCH 80/87] fix docs test --- tox.ini | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index e19ff25..5f471b4 100644 --- a/tox.ini +++ b/tox.ini @@ -1,10 +1,10 @@ [tox] -envlist = pypy,pypy3,py36-docs,py36,py35,py34,py33,py27 +envlist = pypy-test,pypy3-test,py36-test,py35-test,py34-test,py33-test,py27-test,py36-docs [testenv] changedir = docs: {toxinidir}/docs commands = - py{py,py3,36,35,34,33,27}: coverage run --source=fitbit setup.py test + test: coverage run --source=fitbit setup.py test docs: sphinx-build -W -b html docs docs/_build deps = -r{toxinidir}/requirements/test.txt From b8a8404ed394d5b10dc3c5063d27aaf1d16c9b2f Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Tue, 20 Feb 2018 15:36:10 -0700 Subject: [PATCH 81/87] fix docs test, part 2 --- tox.ini | 2 -- 1 file changed, 2 deletions(-) diff --git a/tox.ini b/tox.ini index 5f471b4..f8a6d07 100644 --- a/tox.ini +++ b/tox.ini @@ -2,8 +2,6 @@ envlist = pypy-test,pypy3-test,py36-test,py35-test,py34-test,py33-test,py27-test,py36-docs [testenv] -changedir = - docs: {toxinidir}/docs commands = test: coverage run --source=fitbit setup.py test docs: sphinx-build -W -b html docs docs/_build From 37eb7c9880334ee2690b71da0117d2359d0642a9 Mon Sep 17 00:00:00 2001 From: brad Date: Fri, 5 Oct 2018 09:04:41 -0600 Subject: [PATCH 82/87] drop support for Python 3.3 --- .travis.yml | 2 -- setup.py | 1 - tox.ini | 2 +- 3 files changed, 1 insertion(+), 4 deletions(-) diff --git a/.travis.yml b/.travis.yml index 219cc33..480e7a0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,7 @@ language: python python: - pypy - - pypy3.3-5.2-alpha1 - 2.7 - - 3.3 - 3.4 - 3.5 - 3.6 diff --git a/setup.py b/setup.py index f931edb..f5c4453 100644 --- a/setup.py +++ b/setup.py @@ -35,7 +35,6 @@ 'Programming Language :: Python', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.3', 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', diff --git a/tox.ini b/tox.ini index f8a6d07..d47d1a1 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = pypy-test,pypy3-test,py36-test,py35-test,py34-test,py33-test,py27-test,py36-docs +envlist = pypy-test,py36-test,py35-test,py34-test,py27-test,py36-docs [testenv] commands = From 1392d12862da533119c45df7105a2020d280f810 Mon Sep 17 00:00:00 2001 From: brad Date: Fri, 5 Oct 2018 09:05:29 -0600 Subject: [PATCH 83/87] add pypy3.5 to test matrix --- .travis.yml | 1 + tox.ini | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 480e7a0..ca39904 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,7 @@ language: python python: - pypy + - pypy3.5 - 2.7 - 3.4 - 3.5 diff --git a/tox.ini b/tox.ini index d47d1a1..71533b0 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = pypy-test,py36-test,py35-test,py34-test,py27-test,py36-docs +envlist = pypy-test,pypy3-test,py36-test,py35-test,py34-test,py27-test,py36-docs [testenv] commands = From 8f27805104063f6d3ef8f587b7b4b94c9724f4a0 Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Thu, 28 Feb 2019 10:39:17 -0800 Subject: [PATCH 84/87] pass client_secret kwarg to fetch method --- fitbit/api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/fitbit/api.py b/fitbit/api.py index ba9d037..1b458b1 100644 --- a/fitbit/api.py +++ b/fitbit/api.py @@ -143,6 +143,7 @@ def fetch_access_token(self, code, redirect_uri=None): self.access_token_url, username=self.client_id, password=self.client_secret, + client_secret=self.client_secret, code=code) def refresh_token(self): From df17c16aaae56593b9419ffe6d3a71c1d642deee Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Fri, 24 May 2019 08:57:05 -0700 Subject: [PATCH 85/87] version 0.3.1 --- CHANGELOG.rst | 4 ++++ fitbit/__init__.py | 6 +++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index db75618..c3184fd 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,7 @@ +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 diff --git a/fitbit/__init__.py b/fitbit/__init__.py index a19bb4a..0368d08 100644 --- a/fitbit/__init__.py +++ b/fitbit/__init__.py @@ -3,7 +3,7 @@ Fitbit API Library ------------------ -:copyright: 2012-2017 ORCAS. +:copyright: 2012-2019 ORCAS. :license: BSD, see LICENSE for more details. """ @@ -17,8 +17,8 @@ __copyright__ = 'Copyright 2012-2017 ORCAS' __license__ = 'Apache 2.0' -__version__ = '0.3.0' -__release__ = '0.3.0' +__version__ = '0.3.1' +__release__ = '0.3.1' # Module namespace. From 2f00d77612b588e3dd56f0aaa73051cdba9ee65b Mon Sep 17 00:00:00 2001 From: Brad Pitcher Date: Fri, 24 May 2019 09:09:39 -0700 Subject: [PATCH 86/87] add pypi badge --- README.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.rst b/README.rst index 90797ba..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 From 1692acad32ea0537d7ae5a467bff72fe41e05869 Mon Sep 17 00:00:00 2001 From: mtoshi Date: Mon, 12 Aug 2019 21:56:43 +0900 Subject: [PATCH 87/87] Add CherryPy server hostname and port control --- gather_keys_oauth2.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/gather_keys_oauth2.py b/gather_keys_oauth2.py index aade911..39a19f8 100755 --- a/gather_keys_oauth2.py +++ b/gather_keys_oauth2.py @@ -6,6 +6,7 @@ import traceback import webbrowser +from urllib.parse import urlparse from base64 import b64encode from fitbit.api import Fitbit from oauthlib.oauth2.rfc6749.errors import MismatchingStateError, MissingTokenError @@ -28,6 +29,8 @@ def __init__(self, client_id, client_secret, timeout=10, ) + self.redirect_uri = redirect_uri + def browser_authorize(self): """ Open a browser to the authorization url and spool up a CherryPy @@ -36,6 +39,12 @@ def browser_authorize(self): 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