├── MANIFEST.in
├── requirements
├── base.txt
├── dev.txt
└── test.txt
├── .gitignore
├── .travis.yml
├── tox.ini
├── AUTHORS
├── fitbit
├── __init__.py
├── compliance.py
├── utils.py
├── exceptions.py
└── api.py
├── LICENSE
├── fitbit_tests
├── __init__.py
├── test_exceptions.py
├── test_auth.py
└── test_api.py
├── setup.py
├── README.rst
├── CHANGELOG.rst
├── gather_keys_oauth2.py
└── docs
├── index.rst
├── make.bat
├── Makefile
└── conf.py
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include LICENSE AUTHORS README.rst requirements/* docs/*
2 |
--------------------------------------------------------------------------------
/requirements/base.txt:
--------------------------------------------------------------------------------
1 | python-dateutil>=1.5
2 | requests-oauthlib>=0.7
3 |
--------------------------------------------------------------------------------
/requirements/dev.txt:
--------------------------------------------------------------------------------
1 | -r base.txt
2 | -r test.txt
3 |
4 | cherrypy>=3.7,<3.9
5 | tox>=1.8,<2.2
6 |
--------------------------------------------------------------------------------
/requirements/test.txt:
--------------------------------------------------------------------------------
1 | coverage>=3.7,<4.0
2 | freezegun>=0.3.8
3 | mock>=1.0
4 | requests-mock>=1.2.0
5 | Sphinx>=1.2,<1.4
6 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.pyc
2 | *.DS_Store
3 | .coverage
4 | .tox
5 | *~
6 | docs/_build
7 | *.egg-info
8 | *.egg
9 | .eggs
10 | dist
11 | build
12 | env
13 | htmlcov
14 |
15 | # Editors
16 | .idea
17 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: python
2 | python:
3 | - pypy
4 | - pypy3.5
5 | - 2.7
6 | - 3.4
7 | - 3.5
8 | - 3.6
9 | install:
10 | - pip install coveralls tox-travis
11 | script: tox
12 | after_success: coveralls
13 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | envlist = pypy-test,pypy3-test,py36-test,py35-test,py34-test,py27-test,py36-docs
3 |
4 | [testenv]
5 | commands =
6 | test: coverage run --source=fitbit setup.py test
7 | docs: sphinx-build -W -b html docs docs/_build
8 | deps = -r{toxinidir}/requirements/test.txt
9 |
--------------------------------------------------------------------------------
/AUTHORS:
--------------------------------------------------------------------------------
1 | Issac Kelly (Kelly Creative Tech)
2 | Percy Perez (ORCAS)
3 | Rebecca Lovewell (Caktus Consulting Group)
4 | Dan Poirier (Caktus Consulting Group)
5 | Brad Pitcher (ORCAS)
6 | Silvio Tomatis
7 | Steven Skoczen
8 | Eric Xu
9 | Josh Gachnang
10 | Lorenzo Mancini
11 | David Grandinetti
12 | Chris Streeter
13 | Mario Sangiorgio
14 |
--------------------------------------------------------------------------------
/fitbit/__init__.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | """
3 | Fitbit API Library
4 | ------------------
5 |
6 | :copyright: 2012-2019 ORCAS.
7 | :license: BSD, see LICENSE for more details.
8 | """
9 |
10 | from .api import Fitbit, FitbitOauth2Client
11 |
12 | # Meta.
13 |
14 | __title__ = 'fitbit'
15 | __author__ = 'Issac Kelly and ORCAS'
16 | __author_email__ = 'bpitcher@orcasinc.com'
17 | __copyright__ = 'Copyright 2012-2017 ORCAS'
18 | __license__ = 'Apache 2.0'
19 |
20 | __version__ = '0.3.1'
21 | __release__ = '0.3.1'
22 |
23 | # Module namespace.
24 |
25 | all_tests = []
26 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2012-2017 ORCAS
2 |
3 | Licensed under the Apache License, Version 2.0 (the "License");
4 | you may not use this file except in compliance with the License.
5 | You may obtain a copy of the License at
6 |
7 | http://www.apache.org/licenses/LICENSE-2.0
8 |
9 | Unless required by applicable law or agreed to in writing, software
10 | distributed under the License is distributed on an "AS IS" BASIS,
11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | See the License for the specific language governing permissions and
13 | limitations under the License.
14 |
--------------------------------------------------------------------------------
/fitbit_tests/__init__.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | from .test_exceptions import ExceptionTest
3 | from .test_auth import Auth2Test
4 | from .test_api import (
5 | APITest,
6 | CollectionResourceTest,
7 | DeleteCollectionResourceTest,
8 | ResourceAccessTest,
9 | SubscriptionsTest,
10 | PartnerAPITest
11 | )
12 |
13 |
14 | def all_tests(consumer_key="", consumer_secret="", user_key=None, user_secret=None):
15 | suite = unittest.TestSuite()
16 | suite.addTest(unittest.makeSuite(ExceptionTest))
17 | suite.addTest(unittest.makeSuite(Auth2Test))
18 | suite.addTest(unittest.makeSuite(APITest))
19 | suite.addTest(unittest.makeSuite(CollectionResourceTest))
20 | suite.addTest(unittest.makeSuite(DeleteCollectionResourceTest))
21 | suite.addTest(unittest.makeSuite(ResourceAccessTest))
22 | suite.addTest(unittest.makeSuite(SubscriptionsTest))
23 | suite.addTest(unittest.makeSuite(PartnerAPITest))
24 | return suite
25 |
--------------------------------------------------------------------------------
/fitbit/compliance.py:
--------------------------------------------------------------------------------
1 | """
2 | The Fitbit API breaks from the OAuth2 RFC standard by returning an "errors"
3 | object list, rather than a single "error" string. This puts hooks in place so
4 | that oauthlib can process an error in the results from access token and refresh
5 | token responses. This is necessary to prevent getting the generic red herring
6 | MissingTokenError.
7 | """
8 |
9 | from json import loads, dumps
10 |
11 | from oauthlib.common import to_unicode
12 |
13 |
14 | def fitbit_compliance_fix(session):
15 |
16 | def _missing_error(r):
17 | token = loads(r.text)
18 | if 'errors' in token:
19 | # Set the error to the first one we have
20 | token['error'] = token['errors'][0]['errorType']
21 | r._content = to_unicode(dumps(token)).encode('UTF-8')
22 | return r
23 |
24 | session.register_compliance_hook('access_token_response', _missing_error)
25 | session.register_compliance_hook('refresh_token_response', _missing_error)
26 | return session
27 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 |
4 | import re
5 |
6 | from setuptools import setup
7 |
8 | required = [line for line in open('requirements/base.txt').read().split("\n") if line != '']
9 | required_test = [line for line in open('requirements/test.txt').read().split("\n") if not line.startswith("-r") and line != '']
10 |
11 | fbinit = open('fitbit/__init__.py').read()
12 | author = re.search("__author__ = '([^']+)'", fbinit).group(1)
13 | author_email = re.search("__author_email__ = '([^']+)'", fbinit).group(1)
14 | version = re.search("__version__ = '([^']+)'", fbinit).group(1)
15 |
16 | setup(
17 | name='fitbit',
18 | version=version,
19 | description='Fitbit API Wrapper.',
20 | long_description=open('README.rst').read(),
21 | author=author,
22 | author_email=author_email,
23 | url='https://github.com/orcasgit/python-fitbit',
24 | packages=['fitbit'],
25 | package_data={'': ['LICENSE']},
26 | include_package_data=True,
27 | install_requires=["setuptools"] + required,
28 | license='Apache 2.0',
29 | test_suite='fitbit_tests.all_tests',
30 | tests_require=required_test,
31 | classifiers=(
32 | 'Intended Audience :: Developers',
33 | 'Natural Language :: English',
34 | 'License :: OSI Approved :: Apache Software License',
35 | 'Programming Language :: Python',
36 | 'Programming Language :: Python :: 2.7',
37 | 'Programming Language :: Python :: 3',
38 | 'Programming Language :: Python :: 3.4',
39 | 'Programming Language :: Python :: 3.5',
40 | 'Programming Language :: Python :: 3.6',
41 | 'Programming Language :: Python :: Implementation :: PyPy'
42 | ),
43 | )
44 |
--------------------------------------------------------------------------------
/fitbit/utils.py:
--------------------------------------------------------------------------------
1 | """
2 | Curry was copied from Django's implementation.
3 |
4 | License is reproduced here.
5 |
6 | Copyright (c) Django Software Foundation and individual contributors.
7 | All rights reserved.
8 |
9 | Redistribution and use in source and binary forms, with or without modification,
10 | are permitted provided that the following conditions are met:
11 |
12 | 1. Redistributions of source code must retain the above copyright notice,
13 | this list of conditions and the following disclaimer.
14 |
15 | 2. Redistributions in binary form must reproduce the above copyright
16 | notice, this list of conditions and the following disclaimer in the
17 | documentation and/or other materials provided with the distribution.
18 |
19 | 3. Neither the name of Django nor the names of its contributors may be used
20 | to endorse or promote products derived from this software without
21 | specific prior written permission.
22 |
23 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
24 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
25 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
26 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
27 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
28 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
29 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
30 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
31 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
32 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
33 | """
34 |
35 |
36 | def curry(_curried_func, *args, **kwargs):
37 | def _curried(*moreargs, **morekwargs):
38 | return _curried_func(*(args+moreargs), **dict(kwargs, **morekwargs))
39 | return _curried
40 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | python-fitbit
2 | =============
3 |
4 | .. image:: https://badge.fury.io/py/fitbit.svg
5 | :target: https://badge.fury.io/py/fitbit
6 | .. image:: https://travis-ci.org/orcasgit/python-fitbit.svg?branch=master
7 | :target: https://travis-ci.org/orcasgit/python-fitbit
8 | :alt: Build Status
9 | .. image:: https://coveralls.io/repos/orcasgit/python-fitbit/badge.png?branch=master
10 | :target: https://coveralls.io/r/orcasgit/python-fitbit?branch=master
11 | :alt: Coverage Status
12 | .. image:: https://requires.io/github/orcasgit/python-fitbit/requirements.png?branch=master
13 | :target: https://requires.io/github/orcasgit/python-fitbit/requirements/?branch=master
14 | :alt: Requirements Status
15 | .. image:: https://badges.gitter.im/orcasgit/python-fitbit.png
16 | :target: https://gitter.im/orcasgit/python-fitbit
17 | :alt: Gitter chat
18 |
19 | Fitbit API Python Client Implementation
20 |
21 | For documentation: `http://python-fitbit.readthedocs.org/ `_
22 |
23 | Requirements
24 | ============
25 |
26 | * Python 2.7+
27 | * `python-dateutil`_ (always)
28 | * `requests-oauthlib`_ (always)
29 | * `Sphinx`_ (to create the documention)
30 | * `tox`_ (for running the tests)
31 | * `coverage`_ (to create test coverage reports)
32 |
33 | .. _python-dateutil: https://pypi.python.org/pypi/python-dateutil/2.4.0
34 | .. _requests-oauthlib: https://pypi.python.org/pypi/requests-oauthlib
35 | .. _Sphinx: https://pypi.python.org/pypi/Sphinx
36 | .. _tox: https://pypi.python.org/pypi/tox
37 | .. _coverage: https://pypi.python.org/pypi/coverage/
38 |
39 | To use the library, you need to install the run time requirements:
40 |
41 | sudo pip install -r requirements/base.txt
42 |
43 | To modify and test the library, you need to install the developer requirements:
44 |
45 | sudo pip install -r requirements/dev.txt
46 |
47 | To run the library on a continuous integration server, you need to install the test requirements:
48 |
49 | sudo pip install -r requirements/test.txt
50 |
--------------------------------------------------------------------------------
/CHANGELOG.rst:
--------------------------------------------------------------------------------
1 | 0.3.1 (2019-05-24)
2 | ==================
3 | * Fix auth with newer versions of OAuth libraries while retaining backward compatibility
4 |
5 | 0.3.0 (2017-01-24)
6 | ==================
7 | * Surface errors better
8 | * Use requests-oauthlib auto refresh to automatically refresh tokens if possible
9 |
10 | 0.2.4 (2016-11-10)
11 | ==================
12 | * Call a hook if it exists when tokens are refreshed
13 |
14 | 0.2.3 (2016-07-06)
15 | ==================
16 | * Refresh token when it expires
17 |
18 | 0.2.2 (2016-03-30)
19 | ==================
20 | * Refresh token bugfixes
21 |
22 | 0.2.1 (2016-03-28)
23 | ==================
24 | * Update requirements to use requests-oauthlib>=0.6.1
25 |
26 | 0.2 (2016-03-23)
27 | ================
28 |
29 | * Drop OAuth1 support. See `OAuth1 deprecated `_
30 | * Drop py26 and py32 support
31 |
32 | 0.1.3 (2015-02-04)
33 | ==================
34 |
35 | * Support Intraday Time Series API
36 | * Use connection pooling to avoid a TCP and SSL handshake for every API call
37 |
38 | 0.1.2 (2014-09-19)
39 | ==================
40 |
41 | * Quick fix for response objects without a status code
42 |
43 | 0.1.1 (2014-09-18)
44 | ==================
45 |
46 | * Fix the broken foods log date endpoint
47 | * Integrate with travis-ci.org, coveralls.io, and requires.io
48 | * Add HTTPTooManyRequests exception with retry_after_secs information
49 | * Enable adding parameters to authorize token URL
50 |
51 | 0.1.0 (2014-04-15)
52 | ==================
53 |
54 | * Officially test/support Python 3.2+ and PyPy in addition to Python 2.x
55 | * Clean up OAuth workflow, change the API slightly to match oauthlib terminology
56 | * Fix some minor bugs
57 |
58 | 0.0.5 (2014-03-30)
59 | ==================
60 |
61 | * Switch from python-oauth2 to the better supported oauthlib
62 | * Add get_bodyweight and get_bodyfat methods
63 |
64 | 0.0.3 (2014-02-05)
65 | ==================
66 |
67 | * Add get_badges method
68 | * Include error messages in the exception
69 | * Add API for alarms
70 | * Add API for log activity
71 | * Correctly pass headers on requests
72 | * Way more test coverage
73 | * Publish to PyPI
74 |
75 | 0.0.2 (2012-10-02)
76 | ==================
77 |
78 | * Add docs, including Readthedocs support
79 | * Add tests
80 | * Use official oauth2 version from pypi
81 |
82 | 0.0.1 (2012-02-25)
83 | ==================
84 |
85 | * Initial release
86 |
--------------------------------------------------------------------------------
/fitbit/exceptions.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 |
4 | class BadResponse(Exception):
5 | """
6 | Currently used if the response can't be json encoded, despite a .json extension
7 | """
8 | pass
9 |
10 |
11 | class DeleteError(Exception):
12 | """
13 | Used when a delete request did not return a 204
14 | """
15 | pass
16 |
17 |
18 | class Timeout(Exception):
19 | """
20 | Used when a timeout occurs.
21 | """
22 | pass
23 |
24 |
25 | class HTTPException(Exception):
26 | def __init__(self, response, *args, **kwargs):
27 | try:
28 | errors = json.loads(response.content.decode('utf8'))['errors']
29 | message = '\n'.join([error['message'] for error in errors])
30 | except Exception:
31 | if hasattr(response, 'status_code') and response.status_code == 401:
32 | message = response.content.decode('utf8')
33 | else:
34 | message = response
35 | super(HTTPException, self).__init__(message, *args, **kwargs)
36 |
37 |
38 | class HTTPBadRequest(HTTPException):
39 | """Generic >= 400 error
40 | """
41 | pass
42 |
43 |
44 | class HTTPUnauthorized(HTTPException):
45 | """401
46 | """
47 | pass
48 |
49 |
50 | class HTTPForbidden(HTTPException):
51 | """403
52 | """
53 | pass
54 |
55 |
56 | class HTTPNotFound(HTTPException):
57 | """404
58 | """
59 | pass
60 |
61 |
62 | class HTTPConflict(HTTPException):
63 | """409 - returned when creating conflicting resources
64 | """
65 | pass
66 |
67 |
68 | class HTTPTooManyRequests(HTTPException):
69 | """429 - returned when exceeding rate limits
70 | """
71 | pass
72 |
73 |
74 | class HTTPServerError(HTTPException):
75 | """Generic >= 500 error
76 | """
77 | pass
78 |
79 |
80 | def detect_and_raise_error(response):
81 | if response.status_code == 401:
82 | raise HTTPUnauthorized(response)
83 | elif response.status_code == 403:
84 | raise HTTPForbidden(response)
85 | elif response.status_code == 404:
86 | raise HTTPNotFound(response)
87 | elif response.status_code == 409:
88 | raise HTTPConflict(response)
89 | elif response.status_code == 429:
90 | exc = HTTPTooManyRequests(response)
91 | exc.retry_after_secs = int(response.headers['Retry-After'])
92 | raise exc
93 | elif response.status_code >= 500:
94 | raise HTTPServerError(response)
95 | elif response.status_code >= 400:
96 | raise HTTPBadRequest(response)
97 |
--------------------------------------------------------------------------------
/gather_keys_oauth2.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | import cherrypy
3 | import os
4 | import sys
5 | import threading
6 | import traceback
7 | import webbrowser
8 |
9 | from urllib.parse import urlparse
10 | from base64 import b64encode
11 | from fitbit.api import Fitbit
12 | from oauthlib.oauth2.rfc6749.errors import MismatchingStateError, MissingTokenError
13 |
14 |
15 | class OAuth2Server:
16 | def __init__(self, client_id, client_secret,
17 | redirect_uri='http://127.0.0.1:8080/'):
18 | """ Initialize the FitbitOauth2Client """
19 | self.success_html = """
20 | You are now authorized to access the Fitbit API!
21 |
You can close this window
"""
22 | self.failure_html = """
23 | ERROR: %s
You can close this window
%s"""
24 |
25 | self.fitbit = Fitbit(
26 | client_id,
27 | client_secret,
28 | redirect_uri=redirect_uri,
29 | timeout=10,
30 | )
31 |
32 | self.redirect_uri = redirect_uri
33 |
34 | def browser_authorize(self):
35 | """
36 | Open a browser to the authorization url and spool up a CherryPy
37 | server to accept the response
38 | """
39 | url, _ = self.fitbit.client.authorize_token_url()
40 | # Open the web browser in a new thread for command-line browser support
41 | threading.Timer(1, webbrowser.open, args=(url,)).start()
42 |
43 | # Same with redirect_uri hostname and port.
44 | urlparams = urlparse(self.redirect_uri)
45 | cherrypy.config.update({'server.socket_host': urlparams.hostname,
46 | 'server.socket_port': urlparams.port})
47 |
48 | cherrypy.quickstart(self)
49 |
50 | @cherrypy.expose
51 | def index(self, state, code=None, error=None):
52 | """
53 | Receive a Fitbit response containing a verification code. Use the code
54 | to fetch the access_token.
55 | """
56 | error = None
57 | if code:
58 | try:
59 | self.fitbit.client.fetch_access_token(code)
60 | except MissingTokenError:
61 | error = self._fmt_failure(
62 | 'Missing access token parameter.Please check that '
63 | 'you are using the correct client_secret')
64 | except MismatchingStateError:
65 | error = self._fmt_failure('CSRF Warning! Mismatching state')
66 | else:
67 | error = self._fmt_failure('Unknown error while authenticating')
68 | # Use a thread to shutdown cherrypy so we can return HTML first
69 | self._shutdown_cherrypy()
70 | return error if error else self.success_html
71 |
72 | def _fmt_failure(self, message):
73 | tb = traceback.format_tb(sys.exc_info()[2])
74 | tb_html = '%s
' % ('\n'.join(tb)) if tb else ''
75 | return self.failure_html % (message, tb_html)
76 |
77 | def _shutdown_cherrypy(self):
78 | """ Shutdown cherrypy in one second, if it's running """
79 | if cherrypy.engine.state == cherrypy.engine.states.STARTED:
80 | threading.Timer(1, cherrypy.engine.exit).start()
81 |
82 |
83 | if __name__ == '__main__':
84 |
85 | if not (len(sys.argv) == 3):
86 | print("Arguments: client_id and client_secret")
87 | sys.exit(1)
88 |
89 | server = OAuth2Server(*sys.argv[1:])
90 | server.browser_authorize()
91 |
92 | profile = server.fitbit.user_profile_get()
93 | print('You are authorized to access data for the user: {}'.format(
94 | profile['user']['fullName']))
95 |
96 | print('TOKEN\n=====\n')
97 | for key, value in server.fitbit.client.session.token.items():
98 | print('{} = {}'.format(key, value))
99 |
--------------------------------------------------------------------------------
/docs/index.rst:
--------------------------------------------------------------------------------
1 | .. Python-Fitbit documentation master file, created by
2 | sphinx-quickstart on Wed Mar 14 18:51:57 2012.
3 | You can adapt this file completely to your liking, but it should at least
4 | contain the root `toctree` directive.
5 |
6 | Overview
7 | ========
8 |
9 | This is a complete python implementation of the Fitbit API.
10 |
11 | It uses oAuth for authentication, it supports both us and si
12 | measurements
13 |
14 | Quickstart
15 | ==========
16 |
17 | If you are only retrieving data that doesn't require authorization, then you can use the unauthorized interface::
18 |
19 | import fitbit
20 | unauth_client = fitbit.Fitbit('', '')
21 | # certain methods do not require user keys
22 | unauth_client.food_units()
23 |
24 | Here is an example of authorizing with OAuth 2.0::
25 |
26 | # You'll have to gather the tokens on your own, or use
27 | # ./gather_keys_oauth2.py
28 | authd_client = fitbit.Fitbit('', '',
29 | access_token='', refresh_token='')
30 | authd_client.sleep()
31 |
32 | Fitbit API
33 | ==========
34 |
35 | Some assumptions you should note. Anywhere it says user_id=None,
36 | it assumes the current user_id from the credentials given, and passes
37 | a ``-`` through the API. Anywhere it says date=None, it should accept
38 | either ``None`` or a ``date`` or ``datetime`` object
39 | (anything with proper strftime will do), or a string formatted
40 | as ``%Y-%m-%d``.
41 |
42 | .. autoclass:: fitbit.Fitbit
43 | :members:
44 |
45 | .. method:: body(date=None, user_id=None, data=None)
46 |
47 | Get body data: https://dev.fitbit.com/docs/body/
48 |
49 | .. method:: activities(date=None, user_id=None, data=None)
50 |
51 | Get body data: https://dev.fitbit.com/docs/activity/
52 |
53 | .. method:: foods_log(date=None, user_id=None, data=None)
54 |
55 | Get food logs data: https://dev.fitbit.com/docs/food-logging/#get-food-logs
56 |
57 | .. method:: foods_log_water(date=None, user_id=None, data=None)
58 |
59 | Get water logs data: https://dev.fitbit.com/docs/food-logging/#get-water-logs
60 |
61 | .. method:: sleep(date=None, user_id=None, data=None)
62 |
63 | Get sleep data: https://dev.fitbit.com/docs/sleep/
64 |
65 | .. method:: heart(date=None, user_id=None, data=None)
66 |
67 | Get heart rate data: https://dev.fitbit.com/docs/heart-rate/
68 |
69 | .. method:: bp(date=None, user_id=None, data=None)
70 |
71 | Get blood pressure data: https://dev.fitbit.com/docs/heart-rate/
72 |
73 | .. method:: delete_body(log_id)
74 |
75 | Delete a body log, given a log id
76 |
77 | .. method:: delete_activities(log_id)
78 |
79 | Delete an activity log, given a log id
80 |
81 | .. method:: delete_foods_log(log_id)
82 |
83 | Delete a food log, given a log id
84 |
85 | .. method:: delete_foods_log_water(log_id)
86 |
87 | Delete a water log, given a log id
88 |
89 | .. method:: delete_sleep(log_id)
90 |
91 | Delete a sleep log, given a log id
92 |
93 | .. method:: delete_heart(log_id)
94 |
95 | Delete a heart log, given a log id
96 |
97 | .. method:: delete_bp(log_id)
98 |
99 | Delete a blood pressure log, given a log id
100 |
101 | .. method:: recent_foods(user_id=None, qualifier='')
102 |
103 | Get recently logged foods: https://dev.fitbit.com/docs/food-logging/#get-recent-foods
104 |
105 | .. method:: frequent_foods(user_id=None, qualifier='')
106 |
107 | Get frequently logged foods: https://dev.fitbit.com/docs/food-logging/#get-frequent-foods
108 |
109 | .. method:: favorite_foods(user_id=None, qualifier='')
110 |
111 | Get favorited foods: https://dev.fitbit.com/docs/food-logging/#get-favorite-foods
112 |
113 | .. method:: recent_activities(user_id=None, qualifier='')
114 |
115 | Get recently logged activities: https://dev.fitbit.com/docs/activity/#get-recent-activity-types
116 |
117 | .. method:: frequent_activities(user_id=None, qualifier='')
118 |
119 | Get frequently logged activities: https://dev.fitbit.com/docs/activity/#get-frequent-activities
120 |
121 | .. method:: favorite_activities(user_id=None, qualifier='')
122 |
123 | Get favorited foods: https://dev.fitbit.com/docs/activity/#get-favorite-activities
124 |
125 |
126 |
127 | Indices and tables
128 | ==================
129 |
130 | * :ref:`genindex`
131 | * :ref:`modindex`
132 | * :ref:`search`
133 |
--------------------------------------------------------------------------------
/fitbit_tests/test_exceptions.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | import json
3 | import mock
4 | import requests
5 | import sys
6 | from fitbit import Fitbit
7 | from fitbit import exceptions
8 |
9 |
10 | class ExceptionTest(unittest.TestCase):
11 | """
12 | Tests that certain response codes raise certain exceptions
13 | """
14 | client_kwargs = {
15 | "client_id": "",
16 | "client_secret": "",
17 | "access_token": None,
18 | "refresh_token": None
19 | }
20 |
21 | def test_response_ok(self):
22 | """
23 | This mocks a pretty normal resource, that the request was authenticated,
24 | and data was returned. This test should just run and not raise any
25 | exceptions
26 | """
27 | r = mock.Mock(spec=requests.Response)
28 | r.status_code = 200
29 | r.content = b'{"normal": "resource"}'
30 |
31 | f = Fitbit(**self.client_kwargs)
32 | f.client._request = lambda *args, **kwargs: r
33 | f.user_profile_get()
34 |
35 | r.status_code = 202
36 | f.user_profile_get()
37 |
38 | r.status_code = 204
39 | f.user_profile_get()
40 |
41 | def test_response_auth(self):
42 | """
43 | This test checks how the client handles different auth responses, and
44 | the exceptions raised by the client.
45 | """
46 | r = mock.Mock(spec=requests.Response)
47 | r.status_code = 401
48 | json_response = {
49 | "errors": [{
50 | "errorType": "unauthorized",
51 | "message": "Unknown auth error"}
52 | ],
53 | "normal": "resource"
54 | }
55 | r.content = json.dumps(json_response).encode('utf8')
56 |
57 | f = Fitbit(**self.client_kwargs)
58 | f.client._request = lambda *args, **kwargs: r
59 |
60 | self.assertRaises(exceptions.HTTPUnauthorized, f.user_profile_get)
61 |
62 | r.status_code = 403
63 | json_response['errors'][0].update({
64 | "errorType": "forbidden",
65 | "message": "Forbidden"
66 | })
67 | r.content = json.dumps(json_response).encode('utf8')
68 | self.assertRaises(exceptions.HTTPForbidden, f.user_profile_get)
69 |
70 | def test_response_error(self):
71 | """
72 | Tests other HTTP errors
73 | """
74 | r = mock.Mock(spec=requests.Response)
75 | r.content = b'{"normal": "resource"}'
76 |
77 | self.client_kwargs['oauth2'] = True
78 | f = Fitbit(**self.client_kwargs)
79 | f.client._request = lambda *args, **kwargs: r
80 |
81 | r.status_code = 404
82 | self.assertRaises(exceptions.HTTPNotFound, f.user_profile_get)
83 |
84 | r.status_code = 409
85 | self.assertRaises(exceptions.HTTPConflict, f.user_profile_get)
86 |
87 | r.status_code = 500
88 | self.assertRaises(exceptions.HTTPServerError, f.user_profile_get)
89 |
90 | r.status_code = 499
91 | self.assertRaises(exceptions.HTTPBadRequest, f.user_profile_get)
92 |
93 | def test_too_many_requests(self):
94 | """
95 | Tests the 429 response, given in case of exceeding the rate limit
96 | """
97 | r = mock.Mock(spec=requests.Response)
98 | r.content = b"{'normal': 'resource'}"
99 | r.headers = {'Retry-After': '10'}
100 |
101 | f = Fitbit(**self.client_kwargs)
102 | f.client._request = lambda *args, **kwargs: r
103 |
104 | r.status_code = 429
105 | try:
106 | f.user_profile_get()
107 | self.assertEqual(True, False) # Won't run if an exception's raised
108 | except exceptions.HTTPTooManyRequests:
109 | e = sys.exc_info()[1]
110 | self.assertEqual(e.retry_after_secs, 10)
111 |
112 | def test_serialization(self):
113 | """
114 | Tests non-json data returned
115 | """
116 | r = mock.Mock(spec=requests.Response)
117 | r.status_code = 200
118 | r.content = b"iyam not jason"
119 |
120 | f = Fitbit(**self.client_kwargs)
121 | f.client._request = lambda *args, **kwargs: r
122 | self.assertRaises(exceptions.BadResponse, f.user_profile_get)
123 |
124 | def test_delete_error(self):
125 | """
126 | Delete requests should return 204
127 | """
128 | r = mock.Mock(spec=requests.Response)
129 | r.status_code = 201
130 | r.content = b'{"it\'s all": "ok"}'
131 |
132 | f = Fitbit(**self.client_kwargs)
133 | f.client._request = lambda *args, **kwargs: r
134 | self.assertRaises(exceptions.DeleteError, f.delete_activities, 12345)
135 |
--------------------------------------------------------------------------------
/docs/make.bat:
--------------------------------------------------------------------------------
1 | @ECHO OFF
2 |
3 | REM Command file for Sphinx documentation
4 |
5 | if "%SPHINXBUILD%" == "" (
6 | set SPHINXBUILD=sphinx-build
7 | )
8 | set BUILDDIR=_build
9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% .
10 | set I18NSPHINXOPTS=%SPHINXOPTS% .
11 | if NOT "%PAPER%" == "" (
12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS%
13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS%
14 | )
15 |
16 | if "%1" == "" goto help
17 |
18 | if "%1" == "help" (
19 | :help
20 | echo.Please use `make ^` where ^ is one of
21 | echo. html to make standalone HTML files
22 | echo. dirhtml to make HTML files named index.html in directories
23 | echo. singlehtml to make a single large HTML file
24 | echo. pickle to make pickle files
25 | echo. json to make JSON files
26 | echo. htmlhelp to make HTML files and a HTML help project
27 | echo. qthelp to make HTML files and a qthelp project
28 | echo. devhelp to make HTML files and a Devhelp project
29 | echo. epub to make an epub
30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter
31 | echo. text to make text files
32 | echo. man to make manual pages
33 | echo. texinfo to make Texinfo files
34 | echo. gettext to make PO message catalogs
35 | echo. changes to make an overview over all changed/added/deprecated items
36 | echo. linkcheck to check all external links for integrity
37 | echo. doctest to run all doctests embedded in the documentation if enabled
38 | goto end
39 | )
40 |
41 | if "%1" == "clean" (
42 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i
43 | del /q /s %BUILDDIR%\*
44 | goto end
45 | )
46 |
47 | if "%1" == "html" (
48 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html
49 | if errorlevel 1 exit /b 1
50 | echo.
51 | echo.Build finished. The HTML pages are in %BUILDDIR%/html.
52 | goto end
53 | )
54 |
55 | if "%1" == "dirhtml" (
56 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml
57 | if errorlevel 1 exit /b 1
58 | echo.
59 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml.
60 | goto end
61 | )
62 |
63 | if "%1" == "singlehtml" (
64 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml
65 | if errorlevel 1 exit /b 1
66 | echo.
67 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml.
68 | goto end
69 | )
70 |
71 | if "%1" == "pickle" (
72 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle
73 | if errorlevel 1 exit /b 1
74 | echo.
75 | echo.Build finished; now you can process the pickle files.
76 | goto end
77 | )
78 |
79 | if "%1" == "json" (
80 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json
81 | if errorlevel 1 exit /b 1
82 | echo.
83 | echo.Build finished; now you can process the JSON files.
84 | goto end
85 | )
86 |
87 | if "%1" == "htmlhelp" (
88 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp
89 | if errorlevel 1 exit /b 1
90 | echo.
91 | echo.Build finished; now you can run HTML Help Workshop with the ^
92 | .hhp project file in %BUILDDIR%/htmlhelp.
93 | goto end
94 | )
95 |
96 | if "%1" == "qthelp" (
97 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp
98 | if errorlevel 1 exit /b 1
99 | echo.
100 | echo.Build finished; now you can run "qcollectiongenerator" with the ^
101 | .qhcp project file in %BUILDDIR%/qthelp, like this:
102 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Python-Fitbit.qhcp
103 | echo.To view the help file:
104 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Python-Fitbit.ghc
105 | goto end
106 | )
107 |
108 | if "%1" == "devhelp" (
109 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp
110 | if errorlevel 1 exit /b 1
111 | echo.
112 | echo.Build finished.
113 | goto end
114 | )
115 |
116 | if "%1" == "epub" (
117 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub
118 | if errorlevel 1 exit /b 1
119 | echo.
120 | echo.Build finished. The epub file is in %BUILDDIR%/epub.
121 | goto end
122 | )
123 |
124 | if "%1" == "latex" (
125 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
126 | if errorlevel 1 exit /b 1
127 | echo.
128 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex.
129 | goto end
130 | )
131 |
132 | if "%1" == "text" (
133 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text
134 | if errorlevel 1 exit /b 1
135 | echo.
136 | echo.Build finished. The text files are in %BUILDDIR%/text.
137 | goto end
138 | )
139 |
140 | if "%1" == "man" (
141 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man
142 | if errorlevel 1 exit /b 1
143 | echo.
144 | echo.Build finished. The manual pages are in %BUILDDIR%/man.
145 | goto end
146 | )
147 |
148 | if "%1" == "texinfo" (
149 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo
150 | if errorlevel 1 exit /b 1
151 | echo.
152 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo.
153 | goto end
154 | )
155 |
156 | if "%1" == "gettext" (
157 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale
158 | if errorlevel 1 exit /b 1
159 | echo.
160 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale.
161 | goto end
162 | )
163 |
164 | if "%1" == "changes" (
165 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes
166 | if errorlevel 1 exit /b 1
167 | echo.
168 | echo.The overview file is in %BUILDDIR%/changes.
169 | goto end
170 | )
171 |
172 | if "%1" == "linkcheck" (
173 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck
174 | if errorlevel 1 exit /b 1
175 | echo.
176 | echo.Link check complete; look for any errors in the above output ^
177 | or in %BUILDDIR%/linkcheck/output.txt.
178 | goto end
179 | )
180 |
181 | if "%1" == "doctest" (
182 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest
183 | if errorlevel 1 exit /b 1
184 | echo.
185 | echo.Testing of doctests in the sources finished, look at the ^
186 | results in %BUILDDIR%/doctest/output.txt.
187 | goto end
188 | )
189 |
190 | :end
191 |
--------------------------------------------------------------------------------
/docs/Makefile:
--------------------------------------------------------------------------------
1 | # Makefile for Sphinx documentation
2 | #
3 |
4 | # You can set these variables from the command line.
5 | SPHINXOPTS =
6 | SPHINXBUILD = sphinx-build
7 | PAPER =
8 | BUILDDIR = _build
9 |
10 | # Internal variables.
11 | PAPEROPT_a4 = -D latex_paper_size=a4
12 | PAPEROPT_letter = -D latex_paper_size=letter
13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
14 | # the i18n builder cannot share the environment and doctrees with the others
15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
16 |
17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext
18 |
19 | help:
20 | @echo "Please use \`make ' where is one of"
21 | @echo " html to make standalone HTML files"
22 | @echo " dirhtml to make HTML files named index.html in directories"
23 | @echo " singlehtml to make a single large HTML file"
24 | @echo " pickle to make pickle files"
25 | @echo " json to make JSON files"
26 | @echo " htmlhelp to make HTML files and a HTML help project"
27 | @echo " qthelp to make HTML files and a qthelp project"
28 | @echo " devhelp to make HTML files and a Devhelp project"
29 | @echo " epub to make an epub"
30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
31 | @echo " latexpdf to make LaTeX files and run them through pdflatex"
32 | @echo " text to make text files"
33 | @echo " man to make manual pages"
34 | @echo " texinfo to make Texinfo files"
35 | @echo " info to make Texinfo files and run them through makeinfo"
36 | @echo " gettext to make PO message catalogs"
37 | @echo " changes to make an overview of all changed/added/deprecated items"
38 | @echo " linkcheck to check all external links for integrity"
39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)"
40 |
41 | clean:
42 | -rm -rf $(BUILDDIR)/*
43 |
44 | html:
45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
46 | @echo
47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
48 |
49 | dirhtml:
50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
51 | @echo
52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
53 |
54 | singlehtml:
55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
56 | @echo
57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
58 |
59 | pickle:
60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
61 | @echo
62 | @echo "Build finished; now you can process the pickle files."
63 |
64 | json:
65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
66 | @echo
67 | @echo "Build finished; now you can process the JSON files."
68 |
69 | htmlhelp:
70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
71 | @echo
72 | @echo "Build finished; now you can run HTML Help Workshop with the" \
73 | ".hhp project file in $(BUILDDIR)/htmlhelp."
74 |
75 | qthelp:
76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
77 | @echo
78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \
79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:"
80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Python-Fitbit.qhcp"
81 | @echo "To view the help file:"
82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Python-Fitbit.qhc"
83 |
84 | devhelp:
85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
86 | @echo
87 | @echo "Build finished."
88 | @echo "To view the help file:"
89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/Python-Fitbit"
90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Python-Fitbit"
91 | @echo "# devhelp"
92 |
93 | epub:
94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
95 | @echo
96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub."
97 |
98 | latex:
99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
100 | @echo
101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \
103 | "(use \`make latexpdf' here to do that automatically)."
104 |
105 | latexpdf:
106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
107 | @echo "Running LaTeX files through pdflatex..."
108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf
109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
110 |
111 | text:
112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
113 | @echo
114 | @echo "Build finished. The text files are in $(BUILDDIR)/text."
115 |
116 | man:
117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
118 | @echo
119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man."
120 |
121 | texinfo:
122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
123 | @echo
124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
125 | @echo "Run \`make' in that directory to run these through makeinfo" \
126 | "(use \`make info' here to do that automatically)."
127 |
128 | info:
129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
130 | @echo "Running Texinfo files through makeinfo..."
131 | make -C $(BUILDDIR)/texinfo info
132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
133 |
134 | gettext:
135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
136 | @echo
137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
138 |
139 | changes:
140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
141 | @echo
142 | @echo "The overview file is in $(BUILDDIR)/changes."
143 |
144 | linkcheck:
145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
146 | @echo
147 | @echo "Link check complete; look for any errors in the above output " \
148 | "or in $(BUILDDIR)/linkcheck/output.txt."
149 |
150 | doctest:
151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
152 | @echo "Testing of doctests in the sources finished, look at the " \
153 | "results in $(BUILDDIR)/doctest/output.txt."
154 |
--------------------------------------------------------------------------------
/fitbit_tests/test_auth.py:
--------------------------------------------------------------------------------
1 | import copy
2 | import json
3 | import mock
4 | import requests_mock
5 |
6 | from datetime import datetime
7 | from freezegun import freeze_time
8 | from oauthlib.oauth2.rfc6749.errors import InvalidGrantError
9 | from requests.auth import _basic_auth_str
10 | from unittest import TestCase
11 |
12 | from fitbit import Fitbit
13 |
14 |
15 | class Auth2Test(TestCase):
16 | """Add tests for auth part of API
17 | mock the oauth library calls to simulate various responses,
18 | make sure we call the right oauth calls, respond correctly based on the
19 | responses
20 | """
21 | client_kwargs = {
22 | 'client_id': 'fake_id',
23 | 'client_secret': 'fake_secret',
24 | 'redirect_uri': 'http://127.0.0.1:8080',
25 | 'scope': ['fake_scope1']
26 | }
27 |
28 | def test_authorize_token_url(self):
29 | # authorize_token_url calls oauth and returns a URL
30 | fb = Fitbit(**self.client_kwargs)
31 | retval = fb.client.authorize_token_url()
32 | 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])
33 |
34 | def test_authorize_token_url_with_scope(self):
35 | # authorize_token_url calls oauth and returns a URL
36 | fb = Fitbit(**self.client_kwargs)
37 | retval = fb.client.authorize_token_url(scope=self.client_kwargs['scope'])
38 | 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])
39 |
40 | def test_fetch_access_token(self):
41 | # tests the fetching of access token using code and redirect_URL
42 | fb = Fitbit(**self.client_kwargs)
43 | fake_code = "fake_code"
44 | with requests_mock.mock() as m:
45 | m.post(fb.client.access_token_url, text=json.dumps({
46 | 'access_token': 'fake_return_access_token',
47 | 'refresh_token': 'fake_return_refresh_token'
48 | }))
49 | retval = fb.client.fetch_access_token(fake_code)
50 | self.assertEqual("fake_return_access_token", retval['access_token'])
51 | self.assertEqual("fake_return_refresh_token", retval['refresh_token'])
52 |
53 | def test_refresh_token(self):
54 | # test of refresh function
55 | kwargs = copy.copy(self.client_kwargs)
56 | kwargs['access_token'] = 'fake_access_token'
57 | kwargs['refresh_token'] = 'fake_refresh_token'
58 | kwargs['refresh_cb'] = lambda x: None
59 | fb = Fitbit(**kwargs)
60 | with requests_mock.mock() as m:
61 | m.post(fb.client.refresh_token_url, text=json.dumps({
62 | 'access_token': 'fake_return_access_token',
63 | 'refresh_token': 'fake_return_refresh_token'
64 | }))
65 | retval = fb.client.refresh_token()
66 | self.assertEqual("fake_return_access_token", retval['access_token'])
67 | self.assertEqual("fake_return_refresh_token", retval['refresh_token'])
68 |
69 | @freeze_time(datetime.fromtimestamp(1483563319))
70 | def test_auto_refresh_expires_at(self):
71 | """Test of auto_refresh with expired token"""
72 | # 1. first call to _request causes a HTTPUnauthorized
73 | # 2. the token_refresh call is faked
74 | # 3. the second call to _request returns a valid value
75 | refresh_cb = mock.MagicMock()
76 | kwargs = copy.copy(self.client_kwargs)
77 | kwargs.update({
78 | 'access_token': 'fake_access_token',
79 | 'refresh_token': 'fake_refresh_token',
80 | 'expires_at': 1483530000,
81 | 'refresh_cb': refresh_cb,
82 | })
83 |
84 | fb = Fitbit(**kwargs)
85 | profile_url = Fitbit.API_ENDPOINT + '/1/user/-/profile.json'
86 | with requests_mock.mock() as m:
87 | m.get(
88 | profile_url,
89 | text='{"user":{"aboutMe": "python-fitbit developer"}}',
90 | status_code=200
91 | )
92 | token = {
93 | 'access_token': 'fake_return_access_token',
94 | 'refresh_token': 'fake_return_refresh_token',
95 | 'expires_at': 1483570000,
96 | }
97 | m.post(fb.client.refresh_token_url, text=json.dumps(token))
98 | retval = fb.make_request(profile_url)
99 |
100 | self.assertEqual(m.request_history[0].path, '/oauth2/token')
101 | self.assertEqual(
102 | m.request_history[0].headers['Authorization'],
103 | _basic_auth_str(
104 | self.client_kwargs['client_id'],
105 | self.client_kwargs['client_secret']
106 | )
107 | )
108 | self.assertEqual(retval['user']['aboutMe'], "python-fitbit developer")
109 | self.assertEqual("fake_return_access_token", token['access_token'])
110 | self.assertEqual("fake_return_refresh_token", token['refresh_token'])
111 | refresh_cb.assert_called_once_with(token)
112 |
113 | def test_auto_refresh_token_exception(self):
114 | """Test of auto_refresh with Unauthorized exception"""
115 | # 1. first call to _request causes a HTTPUnauthorized
116 | # 2. the token_refresh call is faked
117 | # 3. the second call to _request returns a valid value
118 | refresh_cb = mock.MagicMock()
119 | kwargs = copy.copy(self.client_kwargs)
120 | kwargs.update({
121 | 'access_token': 'fake_access_token',
122 | 'refresh_token': 'fake_refresh_token',
123 | 'refresh_cb': refresh_cb,
124 | })
125 |
126 | fb = Fitbit(**kwargs)
127 | profile_url = Fitbit.API_ENDPOINT + '/1/user/-/profile.json'
128 | with requests_mock.mock() as m:
129 | m.get(profile_url, [{
130 | 'text': json.dumps({
131 | "errors": [{
132 | "errorType": "expired_token",
133 | "message": "Access token expired:"
134 | }]
135 | }),
136 | 'status_code': 401
137 | }, {
138 | 'text': '{"user":{"aboutMe": "python-fitbit developer"}}',
139 | 'status_code': 200
140 | }])
141 | token = {
142 | 'access_token': 'fake_return_access_token',
143 | 'refresh_token': 'fake_return_refresh_token'
144 | }
145 | m.post(fb.client.refresh_token_url, text=json.dumps(token))
146 | retval = fb.make_request(profile_url)
147 |
148 | self.assertEqual(m.request_history[1].path, '/oauth2/token')
149 | self.assertEqual(
150 | m.request_history[1].headers['Authorization'],
151 | _basic_auth_str(
152 | self.client_kwargs['client_id'],
153 | self.client_kwargs['client_secret']
154 | )
155 | )
156 | self.assertEqual(retval['user']['aboutMe'], "python-fitbit developer")
157 | self.assertEqual("fake_return_access_token", token['access_token'])
158 | self.assertEqual("fake_return_refresh_token", token['refresh_token'])
159 | refresh_cb.assert_called_once_with(token)
160 |
161 | def test_auto_refresh_error(self):
162 | """Test of auto_refresh with expired refresh token"""
163 |
164 | refresh_cb = mock.MagicMock()
165 | kwargs = copy.copy(self.client_kwargs)
166 | kwargs.update({
167 | 'access_token': 'fake_access_token',
168 | 'refresh_token': 'fake_refresh_token',
169 | 'refresh_cb': refresh_cb,
170 | })
171 |
172 | fb = Fitbit(**kwargs)
173 | with requests_mock.mock() as m:
174 | response = {
175 | "errors": [{"errorType": "invalid_grant"}],
176 | "success": False
177 | }
178 | m.post(fb.client.refresh_token_url, text=json.dumps(response))
179 | self.assertRaises(InvalidGrantError, fb.client.refresh_token)
180 |
181 |
182 | class fake_response(object):
183 | def __init__(self, code, text):
184 | self.status_code = code
185 | self.text = text
186 | self.content = text
187 |
--------------------------------------------------------------------------------
/docs/conf.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #
3 | # Python-Fitbit documentation build configuration file, created by
4 | # sphinx-quickstart on Wed Mar 14 18:51:57 2012.
5 | #
6 | # This file is execfile()d with the current directory set to its containing dir.
7 | #
8 | # Note that not all possible configuration values are present in this
9 | # autogenerated file.
10 | #
11 | # All configuration values have a default; values that are commented out
12 | # serve to show the default.
13 |
14 | import sys, os
15 |
16 | # If extensions (or modules to document with autodoc) are in another directory,
17 | # add these directories to sys.path here. If the directory is relative to the
18 | # documentation root, use os.path.abspath to make it absolute, like shown here.
19 | sys.path.insert(0, os.path.abspath('..'))
20 |
21 | # -- General configuration -----------------------------------------------------
22 |
23 | # If your documentation needs a minimal Sphinx version, state it here.
24 | #needs_sphinx = '1.0'
25 |
26 | # Add any Sphinx extension module names here, as strings. They can be extensions
27 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
28 | extensions = [
29 | 'sphinx.ext.autodoc',
30 | 'sphinx.ext.viewcode'
31 | ]
32 |
33 | # Add any paths that contain templates here, relative to this directory.
34 | templates_path = ['_templates']
35 |
36 | # The suffix of source filenames.
37 | source_suffix = '.rst'
38 |
39 | # The encoding of source files.
40 | #source_encoding = 'utf-8-sig'
41 |
42 | # The master toctree document.
43 | master_doc = 'index'
44 |
45 | # General information about the project.
46 | import fitbit
47 | project = u'Python-Fitbit'
48 | copyright = fitbit.__copyright__
49 |
50 | # The version info for the project you're documenting, acts as replacement for
51 | # |version| and |release|, also used in various other places throughout the
52 | # built documents.
53 | #
54 | # The short X.Y version.
55 | version = fitbit.__version__
56 | # The full version, including alpha/beta/rc tags.
57 | release = fitbit.__release__
58 |
59 | # The language for content autogenerated by Sphinx. Refer to documentation
60 | # for a list of supported languages.
61 | #language = None
62 |
63 | # There are two options for replacing |today|: either, you set today to some
64 | # non-false value, then it is used:
65 | #today = ''
66 | # Else, today_fmt is used as the format for a strftime call.
67 | #today_fmt = '%B %d, %Y'
68 |
69 | # List of patterns, relative to source directory, that match files and
70 | # directories to ignore when looking for source files.
71 | exclude_patterns = ['_build']
72 |
73 | # The reST default role (used for this markup: `text`) to use for all documents.
74 | #default_role = None
75 |
76 | # If true, '()' will be appended to :func: etc. cross-reference text.
77 | #add_function_parentheses = True
78 |
79 | # If true, the current module name will be prepended to all description
80 | # unit titles (such as .. function::).
81 | #add_module_names = True
82 |
83 | # If true, sectionauthor and moduleauthor directives will be shown in the
84 | # output. They are ignored by default.
85 | #show_authors = False
86 |
87 | # The name of the Pygments (syntax highlighting) style to use.
88 | pygments_style = 'sphinx'
89 |
90 | # A list of ignored prefixes for module index sorting.
91 | #modindex_common_prefix = []
92 |
93 |
94 | # -- Options for HTML output ---------------------------------------------------
95 |
96 | # The theme to use for HTML and HTML Help pages. See the documentation for
97 | # a list of builtin themes.
98 | html_theme = 'alabaster'
99 |
100 | # Theme options are theme-specific and customize the look and feel of a theme
101 | # further. For a list of options available for each theme, see the
102 | # documentation.
103 | #html_theme_options = {}
104 |
105 | # Add any paths that contain custom themes here, relative to this directory.
106 | #html_theme_path = []
107 |
108 | # The name for this set of Sphinx documents. If None, it defaults to
109 | # " v documentation".
110 | #html_title = None
111 |
112 | # A shorter title for the navigation bar. Default is the same as html_title.
113 | #html_short_title = None
114 |
115 | # The name of an image file (relative to this directory) to place at the top
116 | # of the sidebar.
117 | #html_logo = None
118 |
119 | # The name of an image file (within the static path) to use as favicon of the
120 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
121 | # pixels large.
122 | #html_favicon = None
123 |
124 | # Add any paths that contain custom static files (such as style sheets) here,
125 | # relative to this directory. They are copied after the builtin static files,
126 | # so a file named "default.css" will overwrite the builtin "default.css".
127 | html_static_path = []
128 |
129 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
130 | # using the given strftime format.
131 | #html_last_updated_fmt = '%b %d, %Y'
132 |
133 | # If true, SmartyPants will be used to convert quotes and dashes to
134 | # typographically correct entities.
135 | #html_use_smartypants = True
136 |
137 | # Custom sidebar templates, maps document names to template names.
138 | #html_sidebars = {}
139 |
140 | # Additional templates that should be rendered to pages, maps page names to
141 | # template names.
142 | #html_additional_pages = {}
143 |
144 | # If false, no module index is generated.
145 | #html_domain_indices = True
146 |
147 | # If false, no index is generated.
148 | #html_use_index = True
149 |
150 | # If true, the index is split into individual pages for each letter.
151 | #html_split_index = False
152 |
153 | # If true, links to the reST sources are added to the pages.
154 | #html_show_sourcelink = True
155 |
156 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
157 | #html_show_sphinx = True
158 |
159 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
160 | #html_show_copyright = True
161 |
162 | # If true, an OpenSearch description file will be output, and all pages will
163 | # contain a tag referring to it. The value of this option must be the
164 | # base URL from which the finished HTML is served.
165 | #html_use_opensearch = ''
166 |
167 | # This is the file name suffix for HTML files (e.g. ".xhtml").
168 | #html_file_suffix = None
169 |
170 | # Output file base name for HTML help builder.
171 | htmlhelp_basename = 'Python-Fitbitdoc'
172 |
173 |
174 | # -- Options for LaTeX output --------------------------------------------------
175 |
176 | latex_elements = {
177 | # The paper size ('letterpaper' or 'a4paper').
178 | #'papersize': 'letterpaper',
179 |
180 | # The font size ('10pt', '11pt' or '12pt').
181 | #'pointsize': '10pt',
182 |
183 | # Additional stuff for the LaTeX preamble.
184 | #'preamble': '',
185 | }
186 |
187 | # Grouping the document tree into LaTeX files. List of tuples
188 | # (source start file, target name, title, author, documentclass [howto/manual]).
189 | latex_documents = [
190 | ('index', 'Python-Fitbit.tex', u'Python-Fitbit Documentation',
191 | u'Issac Kelly, Percy Perez, Brad Pitcher', 'manual'),
192 | ]
193 |
194 | # The name of an image file (relative to this directory) to place at the top of
195 | # the title page.
196 | #latex_logo = None
197 |
198 | # For "manual" documents, if this is true, then toplevel headings are parts,
199 | # not chapters.
200 | #latex_use_parts = False
201 |
202 | # If true, show page references after internal links.
203 | #latex_show_pagerefs = False
204 |
205 | # If true, show URL addresses after external links.
206 | #latex_show_urls = False
207 |
208 | # Documents to append as an appendix to all manuals.
209 | #latex_appendices = []
210 |
211 | # If false, no module index is generated.
212 | #latex_domain_indices = True
213 |
214 |
215 | # -- Options for manual page output --------------------------------------------
216 |
217 | # One entry per manual page. List of tuples
218 | # (source start file, name, description, authors, manual section).
219 | man_pages = [
220 | ('index', 'python-fitbit', u'Python-Fitbit Documentation',
221 | [u'Issac Kelly, Percy Perez, Brad Pitcher'], 1)
222 | ]
223 |
224 | # If true, show URL addresses after external links.
225 | #man_show_urls = False
226 |
227 |
228 | # -- Options for Texinfo output ------------------------------------------------
229 |
230 | # Grouping the document tree into Texinfo files. List of tuples
231 | # (source start file, target name, title, author,
232 | # dir menu entry, description, category)
233 | texinfo_documents = [
234 | ('index', 'Python-Fitbit', u'Python-Fitbit Documentation',
235 | u'Issac Kelly, Percy Perez, Brad Pitcher', 'Python-Fitbit', 'Fitbit API Python Client Implementation',
236 | 'Miscellaneous'),
237 | ]
238 |
239 | # Documents to append as an appendix to all manuals.
240 | #texinfo_appendices = []
241 |
242 | # If false, no module index is generated.
243 | #texinfo_domain_indices = True
244 |
245 | # How to display URL addresses: 'footnote', 'no', or 'inline'.
246 | #texinfo_show_urls = 'footnote'
247 |
--------------------------------------------------------------------------------
/fitbit_tests/test_api.py:
--------------------------------------------------------------------------------
1 | from unittest import TestCase
2 | import datetime
3 | import mock
4 | import requests
5 | from fitbit import Fitbit
6 | from fitbit.exceptions import DeleteError, Timeout
7 |
8 | URLBASE = "%s/%s/user" % (Fitbit.API_ENDPOINT, Fitbit.API_VERSION)
9 |
10 |
11 | class TestBase(TestCase):
12 | def setUp(self):
13 | self.fb = Fitbit('x', 'y')
14 |
15 | def common_api_test(self, funcname, args, kwargs, expected_args, expected_kwargs):
16 | # Create a fitbit object, call the named function on it with the given
17 | # arguments and verify that make_request is called with the expected args and kwargs
18 | with mock.patch.object(self.fb, 'make_request') as make_request:
19 | retval = getattr(self.fb, funcname)(*args, **kwargs)
20 | mr_args, mr_kwargs = make_request.call_args
21 | self.assertEqual(expected_args, mr_args)
22 | self.assertEqual(expected_kwargs, mr_kwargs)
23 |
24 | def verify_raises(self, funcname, args, kwargs, exc):
25 | self.assertRaises(exc, getattr(self.fb, funcname), *args, **kwargs)
26 |
27 |
28 | class TimeoutTest(TestCase):
29 |
30 | def setUp(self):
31 | self.fb = Fitbit('x', 'y')
32 | self.fb_timeout = Fitbit('x', 'y', timeout=10)
33 |
34 | self.test_url = 'invalid://do.not.connect'
35 |
36 | def test_fb_without_timeout(self):
37 | with mock.patch.object(self.fb.client.session, 'request') as request:
38 | mock_response = mock.Mock()
39 | mock_response.status_code = 200
40 | mock_response.content = b'{}'
41 | request.return_value = mock_response
42 | result = self.fb.make_request(self.test_url)
43 |
44 | request.assert_called_once()
45 | self.assertNotIn('timeout', request.call_args[1])
46 | self.assertEqual({}, result)
47 |
48 | def test_fb_with_timeout__timing_out(self):
49 | with mock.patch.object(self.fb_timeout.client.session, 'request') as request:
50 | request.side_effect = requests.Timeout('Timed out')
51 | with self.assertRaisesRegexp(Timeout, 'Timed out'):
52 | self.fb_timeout.make_request(self.test_url)
53 |
54 | request.assert_called_once()
55 | self.assertEqual(10, request.call_args[1]['timeout'])
56 |
57 | def test_fb_with_timeout__not_timing_out(self):
58 | with mock.patch.object(self.fb_timeout.client.session, 'request') as request:
59 | mock_response = mock.Mock()
60 | mock_response.status_code = 200
61 | mock_response.content = b'{}'
62 | request.return_value = mock_response
63 |
64 | result = self.fb_timeout.make_request(self.test_url)
65 |
66 | request.assert_called_once()
67 | self.assertEqual(10, request.call_args[1]['timeout'])
68 | self.assertEqual({}, result)
69 |
70 |
71 | class APITest(TestBase):
72 | """
73 | Tests for python-fitbit API, not directly involved in getting
74 | authenticated
75 | """
76 |
77 | def test_make_request(self):
78 | # If make_request returns a response with status 200,
79 | # we get back the json decoded value that was in the response.content
80 | ARGS = (1, 2)
81 | KWARGS = {'a': 3, 'b': 4, 'headers': {'Accept-Language': self.fb.system}}
82 | mock_response = mock.Mock()
83 | mock_response.status_code = 200
84 | mock_response.content = b"1"
85 | with mock.patch.object(self.fb.client, 'make_request') as client_make_request:
86 | client_make_request.return_value = mock_response
87 | retval = self.fb.make_request(*ARGS, **KWARGS)
88 | self.assertEqual(1, client_make_request.call_count)
89 | self.assertEqual(1, retval)
90 | args, kwargs = client_make_request.call_args
91 | self.assertEqual(ARGS, args)
92 | self.assertEqual(KWARGS, kwargs)
93 |
94 | def test_make_request_202(self):
95 | # If make_request returns a response with status 202,
96 | # we get back True
97 | mock_response = mock.Mock()
98 | mock_response.status_code = 202
99 | mock_response.content = "1"
100 | ARGS = (1, 2)
101 | KWARGS = {'a': 3, 'b': 4, 'Accept-Language': self.fb.system}
102 | with mock.patch.object(self.fb.client, 'make_request') as client_make_request:
103 | client_make_request.return_value = mock_response
104 | retval = self.fb.make_request(*ARGS, **KWARGS)
105 | self.assertEqual(True, retval)
106 |
107 | def test_make_request_delete_204(self):
108 | # If make_request returns a response with status 204,
109 | # and the method is DELETE, we get back True
110 | mock_response = mock.Mock()
111 | mock_response.status_code = 204
112 | mock_response.content = "1"
113 | ARGS = (1, 2)
114 | KWARGS = {'a': 3, 'b': 4, 'method': 'DELETE', 'Accept-Language': self.fb.system}
115 | with mock.patch.object(self.fb.client, 'make_request') as client_make_request:
116 | client_make_request.return_value = mock_response
117 | retval = self.fb.make_request(*ARGS, **KWARGS)
118 | self.assertEqual(True, retval)
119 |
120 | def test_make_request_delete_not_204(self):
121 | # If make_request returns a response with status not 204,
122 | # and the method is DELETE, DeleteError is raised
123 | mock_response = mock.Mock()
124 | mock_response.status_code = 205
125 | mock_response.content = "1"
126 | ARGS = (1, 2)
127 | KWARGS = {'a': 3, 'b': 4, 'method': 'DELETE', 'Accept-Language': self.fb.system}
128 | with mock.patch.object(self.fb.client, 'make_request') as client_make_request:
129 | client_make_request.return_value = mock_response
130 | self.assertRaises(DeleteError, self.fb.make_request, *ARGS, **KWARGS)
131 |
132 |
133 | class CollectionResourceTest(TestBase):
134 | """ Tests for _COLLECTION_RESOURCE """
135 | def test_all_args(self):
136 | # If we pass all the optional args, the right things happen
137 | resource = "RESOURCE"
138 | date = datetime.date(1962, 1, 13)
139 | user_id = "bilbo"
140 | data = {'a': 1, 'b': 2}
141 | expected_data = data.copy()
142 | expected_data['date'] = date.strftime("%Y-%m-%d")
143 | url = URLBASE + "/%s/%s.json" % (user_id, resource)
144 | self.common_api_test('_COLLECTION_RESOURCE', (resource, date, user_id, data), {}, (url, expected_data), {})
145 |
146 | def test_date_string(self):
147 | # date can be a "yyyy-mm-dd" string
148 | resource = "RESOURCE"
149 | date = "1962-1-13"
150 | user_id = "bilbo"
151 | data = {'a': 1, 'b': 2}
152 | expected_data = data.copy()
153 | expected_data['date'] = date
154 | url = URLBASE + "/%s/%s.json" % (user_id, resource)
155 | self.common_api_test('_COLLECTION_RESOURCE', (resource, date, user_id, data), {}, (url, expected_data), {})
156 |
157 | def test_no_date(self):
158 | # If we omit the date, it uses today
159 | resource = "RESOURCE"
160 | user_id = "bilbo"
161 | data = {'a': 1, 'b': 2}
162 | expected_data = data.copy()
163 | expected_data['date'] = datetime.date.today().strftime("%Y-%m-%d") # expect today
164 | url = URLBASE + "/%s/%s.json" % (user_id, resource)
165 | self.common_api_test('_COLLECTION_RESOURCE', (resource, None, user_id, data), {}, (url, expected_data), {})
166 |
167 | def test_no_userid(self):
168 | # If we omit the user_id, it uses "-"
169 | resource = "RESOURCE"
170 | date = datetime.date(1962, 1, 13)
171 | user_id = None
172 | data = {'a': 1, 'b': 2}
173 | expected_data = data.copy()
174 | expected_data['date'] = date.strftime("%Y-%m-%d")
175 | expected_user_id = "-"
176 | url = URLBASE + "/%s/%s.json" % (expected_user_id, resource)
177 | self.common_api_test(
178 | '_COLLECTION_RESOURCE',
179 | (resource, date, user_id, data), {},
180 | (url, expected_data),
181 | {}
182 | )
183 |
184 | def test_no_data(self):
185 | # If we omit the data arg, it does the right thing
186 | resource = "RESOURCE"
187 | date = datetime.date(1962, 1, 13)
188 | user_id = "bilbo"
189 | data = None
190 | url = URLBASE + "/%s/%s/date/%s.json" % (user_id, resource, date)
191 | self.common_api_test('_COLLECTION_RESOURCE', (resource, date, user_id, data), {}, (url, data), {})
192 |
193 | def test_body(self):
194 | # Test the first method defined in __init__ to see if it calls
195 | # _COLLECTION_RESOURCE okay - if it does, they should all since
196 | # they're all built the same way
197 |
198 | # We need to mock _COLLECTION_RESOURCE before we create the Fitbit object,
199 | # since the __init__ is going to set up references to it
200 | with mock.patch('fitbit.api.Fitbit._COLLECTION_RESOURCE') as coll_resource:
201 | coll_resource.return_value = 999
202 | fb = Fitbit('x', 'y')
203 | retval = fb.body(date=1, user_id=2, data=3)
204 | args, kwargs = coll_resource.call_args
205 | self.assertEqual(('body',), args)
206 | self.assertEqual({'date': 1, 'user_id': 2, 'data': 3}, kwargs)
207 | self.assertEqual(999, retval)
208 |
209 |
210 | class DeleteCollectionResourceTest(TestBase):
211 | """Tests for _DELETE_COLLECTION_RESOURCE"""
212 | def test_impl(self):
213 | # _DELETE_COLLECTION_RESOURCE calls make_request with the right args
214 | resource = "RESOURCE"
215 | log_id = "Foo"
216 | url = URLBASE + "/-/%s/%s.json" % (resource, log_id)
217 | self.common_api_test(
218 | '_DELETE_COLLECTION_RESOURCE',
219 | (resource, log_id), {},
220 | (url,),
221 | {"method": "DELETE"}
222 | )
223 |
224 | def test_cant_delete_body(self):
225 | self.assertFalse(hasattr(self.fb, 'delete_body'))
226 |
227 | def test_delete_foods_log(self):
228 | log_id = "fake_log_id"
229 | # We need to mock _DELETE_COLLECTION_RESOURCE before we create the Fitbit object,
230 | # since the __init__ is going to set up references to it
231 | with mock.patch('fitbit.api.Fitbit._DELETE_COLLECTION_RESOURCE') as delete_resource:
232 | delete_resource.return_value = 999
233 | fb = Fitbit('x', 'y')
234 | retval = fb.delete_foods_log(log_id=log_id)
235 | args, kwargs = delete_resource.call_args
236 | self.assertEqual(('foods/log',), args)
237 | self.assertEqual({'log_id': log_id}, kwargs)
238 | self.assertEqual(999, retval)
239 |
240 | def test_delete_foods_log_water(self):
241 | log_id = "OmarKhayyam"
242 | # We need to mock _DELETE_COLLECTION_RESOURCE before we create the Fitbit object,
243 | # since the __init__ is going to set up references to it
244 | with mock.patch('fitbit.api.Fitbit._DELETE_COLLECTION_RESOURCE') as delete_resource:
245 | delete_resource.return_value = 999
246 | fb = Fitbit('x', 'y')
247 | retval = fb.delete_foods_log_water(log_id=log_id)
248 | args, kwargs = delete_resource.call_args
249 | self.assertEqual(('foods/log/water',), args)
250 | self.assertEqual({'log_id': log_id}, kwargs)
251 | self.assertEqual(999, retval)
252 |
253 |
254 | class ResourceAccessTest(TestBase):
255 | """
256 | Class for testing the Fitbit Resource Access API:
257 | https://dev.fitbit.com/docs/
258 | """
259 | def test_user_profile_get(self):
260 | """
261 | Test getting a user profile.
262 | https://dev.fitbit.com/docs/user/
263 |
264 | Tests the following HTTP method/URLs:
265 | GET https://api.fitbit.com/1/user/FOO/profile.json
266 | GET https://api.fitbit.com/1/user/-/profile.json
267 | """
268 | user_id = "FOO"
269 | url = URLBASE + "/%s/profile.json" % user_id
270 | self.common_api_test('user_profile_get', (user_id,), {}, (url,), {})
271 | url = URLBASE + "/-/profile.json"
272 | self.common_api_test('user_profile_get', (), {}, (url,), {})
273 |
274 | def test_user_profile_update(self):
275 | """
276 | Test updating a user profile.
277 | https://dev.fitbit.com/docs/user/#update-profile
278 |
279 | Tests the following HTTP method/URLs:
280 | POST https://api.fitbit.com/1/user/-/profile.json
281 | """
282 | data = "BAR"
283 | url = URLBASE + "/-/profile.json"
284 | self.common_api_test('user_profile_update', (data,), {}, (url, data), {})
285 |
286 | def test_recent_activities(self):
287 | user_id = "LukeSkywalker"
288 | with mock.patch('fitbit.api.Fitbit.activity_stats') as act_stats:
289 | fb = Fitbit('x', 'y')
290 | retval = fb.recent_activities(user_id=user_id)
291 | args, kwargs = act_stats.call_args
292 | self.assertEqual((), args)
293 | self.assertEqual({'user_id': user_id, 'qualifier': 'recent'}, kwargs)
294 |
295 | def test_activity_stats(self):
296 | user_id = "O B 1 Kenobi"
297 | qualifier = "frequent"
298 | url = URLBASE + "/%s/activities/%s.json" % (user_id, qualifier)
299 | self.common_api_test('activity_stats', (), dict(user_id=user_id, qualifier=qualifier), (url,), {})
300 |
301 | def test_activity_stats_no_qualifier(self):
302 | user_id = "O B 1 Kenobi"
303 | qualifier = None
304 | self.common_api_test('activity_stats', (), dict(user_id=user_id, qualifier=qualifier), (URLBASE + "/%s/activities.json" % user_id,), {})
305 |
306 | def test_body_fat_goal(self):
307 | self.common_api_test(
308 | 'body_fat_goal', (), dict(),
309 | (URLBASE + '/-/body/log/fat/goal.json',), {'data': {}})
310 | self.common_api_test(
311 | 'body_fat_goal', (), dict(fat=10),
312 | (URLBASE + '/-/body/log/fat/goal.json',), {'data': {'fat': 10}})
313 |
314 | def test_body_weight_goal(self):
315 | self.common_api_test(
316 | 'body_weight_goal', (), dict(),
317 | (URLBASE + '/-/body/log/weight/goal.json',), {'data': {}})
318 | self.common_api_test(
319 | 'body_weight_goal', (), dict(start_date='2015-04-01', start_weight=180),
320 | (URLBASE + '/-/body/log/weight/goal.json',),
321 | {'data': {'startDate': '2015-04-01', 'startWeight': 180}})
322 | self.verify_raises('body_weight_goal', (), {'start_date': '2015-04-01'}, ValueError)
323 | self.verify_raises('body_weight_goal', (), {'start_weight': 180}, ValueError)
324 |
325 | def test_activities_daily_goal(self):
326 | self.common_api_test(
327 | 'activities_daily_goal', (), dict(),
328 | (URLBASE + '/-/activities/goals/daily.json',), {'data': {}})
329 | self.common_api_test(
330 | 'activities_daily_goal', (), dict(steps=10000),
331 | (URLBASE + '/-/activities/goals/daily.json',), {'data': {'steps': 10000}})
332 | self.common_api_test(
333 | 'activities_daily_goal', (),
334 | dict(calories_out=3107, active_minutes=30, floors=10, distance=5, steps=10000),
335 | (URLBASE + '/-/activities/goals/daily.json',),
336 | {'data': {'caloriesOut': 3107, 'activeMinutes': 30, 'floors': 10, 'distance': 5, 'steps': 10000}})
337 |
338 | def test_activities_weekly_goal(self):
339 | self.common_api_test(
340 | 'activities_weekly_goal', (), dict(),
341 | (URLBASE + '/-/activities/goals/weekly.json',), {'data': {}})
342 | self.common_api_test(
343 | 'activities_weekly_goal', (), dict(steps=10000),
344 | (URLBASE + '/-/activities/goals/weekly.json',), {'data': {'steps': 10000}})
345 | self.common_api_test(
346 | 'activities_weekly_goal', (),
347 | dict(floors=10, distance=5, steps=10000),
348 | (URLBASE + '/-/activities/goals/weekly.json',),
349 | {'data': {'floors': 10, 'distance': 5, 'steps': 10000}})
350 |
351 | def test_food_goal(self):
352 | self.common_api_test(
353 | 'food_goal', (), dict(),
354 | (URLBASE + '/-/foods/log/goal.json',), {'data': {}})
355 | self.common_api_test(
356 | 'food_goal', (), dict(calories=2300),
357 | (URLBASE + '/-/foods/log/goal.json',), {'data': {'calories': 2300}})
358 | self.common_api_test(
359 | 'food_goal', (), dict(intensity='EASIER', personalized=True),
360 | (URLBASE + '/-/foods/log/goal.json',),
361 | {'data': {'intensity': 'EASIER', 'personalized': True}})
362 | self.verify_raises('food_goal', (), {'personalized': True}, ValueError)
363 |
364 | def test_water_goal(self):
365 | self.common_api_test(
366 | 'water_goal', (), dict(),
367 | (URLBASE + '/-/foods/log/water/goal.json',), {'data': {}})
368 | self.common_api_test(
369 | 'water_goal', (), dict(target=63),
370 | (URLBASE + '/-/foods/log/water/goal.json',), {'data': {'target': 63}})
371 |
372 | def test_timeseries(self):
373 | resource = 'FOO'
374 | user_id = 'BAR'
375 | base_date = '1992-05-12'
376 | period = '1d'
377 | end_date = '1998-12-31'
378 |
379 | # Not allowed to specify both period and end date
380 | self.assertRaises(
381 | TypeError,
382 | self.fb.time_series,
383 | resource,
384 | user_id,
385 | base_date,
386 | period,
387 | end_date)
388 |
389 | # Period must be valid
390 | self.assertRaises(
391 | ValueError,
392 | self.fb.time_series,
393 | resource,
394 | user_id,
395 | base_date,
396 | period="xyz",
397 | end_date=None)
398 |
399 | def test_timeseries(fb, resource, user_id, base_date, period, end_date, expected_url):
400 | with mock.patch.object(fb, 'make_request') as make_request:
401 | retval = fb.time_series(resource, user_id, base_date, period, end_date)
402 | args, kwargs = make_request.call_args
403 | self.assertEqual((expected_url,), args)
404 |
405 | # User_id defaults = "-"
406 | test_timeseries(self.fb, resource, user_id=None, base_date=base_date, period=period, end_date=None,
407 | expected_url=URLBASE + "/-/FOO/date/1992-05-12/1d.json")
408 | # end_date can be a date object
409 | test_timeseries(self.fb, resource, user_id=user_id, base_date=base_date, period=None, end_date=datetime.date(1998, 12, 31),
410 | expected_url=URLBASE + "/BAR/FOO/date/1992-05-12/1998-12-31.json")
411 | # base_date can be a date object
412 | test_timeseries(self.fb, resource, user_id=user_id, base_date=datetime.date(1992,5,12), period=None, end_date=end_date,
413 | expected_url=URLBASE + "/BAR/FOO/date/1992-05-12/1998-12-31.json")
414 |
415 | def test_sleep(self):
416 | today = datetime.date.today().strftime('%Y-%m-%d')
417 | self.common_api_test('sleep', (today,), {}, ("%s/-/sleep/date/%s.json" % (URLBASE, today), None), {})
418 | self.common_api_test('sleep', (today, "USER_ID"), {}, ("%s/USER_ID/sleep/date/%s.json" % (URLBASE, today), None), {})
419 |
420 | def test_foods(self):
421 | today = datetime.date.today().strftime('%Y-%m-%d')
422 | self.common_api_test('recent_foods', ("USER_ID",), {}, (URLBASE+"/USER_ID/foods/log/recent.json",), {})
423 | self.common_api_test('favorite_foods', ("USER_ID",), {}, (URLBASE+"/USER_ID/foods/log/favorite.json",), {})
424 | self.common_api_test('frequent_foods', ("USER_ID",), {}, (URLBASE+"/USER_ID/foods/log/frequent.json",), {})
425 | self.common_api_test('foods_log', (today, "USER_ID",), {}, ("%s/USER_ID/foods/log/date/%s.json" % (URLBASE, today), None), {})
426 | self.common_api_test('recent_foods', (), {}, (URLBASE+"/-/foods/log/recent.json",), {})
427 | self.common_api_test('favorite_foods', (), {}, (URLBASE+"/-/foods/log/favorite.json",), {})
428 | self.common_api_test('frequent_foods', (), {}, (URLBASE+"/-/foods/log/frequent.json",), {})
429 | self.common_api_test('foods_log', (today,), {}, ("%s/-/foods/log/date/%s.json" % (URLBASE, today), None), {})
430 |
431 | url = URLBASE + "/-/foods/log/favorite/food_id.json"
432 | self.common_api_test('add_favorite_food', ('food_id',), {}, (url,), {'method': 'POST'})
433 | self.common_api_test('delete_favorite_food', ('food_id',), {}, (url,), {'method': 'DELETE'})
434 |
435 | url = URLBASE + "/-/foods.json"
436 | self.common_api_test('create_food', (), {'data': 'FOO'}, (url,), {'data': 'FOO'})
437 | url = URLBASE + "/-/meals.json"
438 | self.common_api_test('get_meals', (), {}, (url,), {})
439 | url = "%s/%s/foods/search.json?query=FOOBAR" % (Fitbit.API_ENDPOINT, Fitbit.API_VERSION)
440 | self.common_api_test('search_foods', ("FOOBAR",), {}, (url,), {})
441 | url = "%s/%s/foods/FOOBAR.json" % (Fitbit.API_ENDPOINT, Fitbit.API_VERSION)
442 | self.common_api_test('food_detail', ("FOOBAR",), {}, (url,), {})
443 | url = "%s/%s/foods/units.json" % (Fitbit.API_ENDPOINT, Fitbit.API_VERSION)
444 | self.common_api_test('food_units', (), {}, (url,), {})
445 |
446 | def test_devices(self):
447 | url = URLBASE + "/-/devices.json"
448 | self.common_api_test('get_devices', (), {}, (url,), {})
449 |
450 | def test_badges(self):
451 | url = URLBASE + "/-/badges.json"
452 | self.common_api_test('get_badges', (), {}, (url,), {})
453 |
454 | def test_activities(self):
455 | """
456 | Test the getting/creating/deleting various activity related items.
457 | Tests the following HTTP method/URLs:
458 |
459 | GET https://api.fitbit.com/1/activities.json
460 | POST https://api.fitbit.com/1/user/-/activities.json
461 | GET https://api.fitbit.com/1/activities/FOOBAR.json
462 | POST https://api.fitbit.com/1/user/-/activities/favorite/activity_id.json
463 | DELETE https://api.fitbit.com/1/user/-/activities/favorite/activity_id.json
464 | """
465 | url = "%s/%s/activities.json" % (Fitbit.API_ENDPOINT, Fitbit.API_VERSION)
466 | self.common_api_test('activities_list', (), {}, (url,), {})
467 | url = "%s/%s/user/-/activities.json" % (Fitbit.API_ENDPOINT, Fitbit.API_VERSION)
468 | self.common_api_test('log_activity', (), {'data' : 'FOO'}, (url,), {'data' : 'FOO'} )
469 | url = "%s/%s/activities/FOOBAR.json" % (Fitbit.API_ENDPOINT, Fitbit.API_VERSION)
470 | self.common_api_test('activity_detail', ("FOOBAR",), {}, (url,), {})
471 |
472 | url = URLBASE + "/-/activities/favorite/activity_id.json"
473 | self.common_api_test('add_favorite_activity', ('activity_id',), {}, (url,), {'method': 'POST'})
474 | self.common_api_test('delete_favorite_activity', ('activity_id',), {}, (url,), {'method': 'DELETE'})
475 |
476 | def _test_get_bodyweight(self, base_date=None, user_id=None, period=None,
477 | end_date=None, expected_url=None):
478 | """ Helper method for testing retrieving body weight measurements """
479 | with mock.patch.object(self.fb, 'make_request') as make_request:
480 | self.fb.get_bodyweight(base_date, user_id=user_id, period=period,
481 | end_date=end_date)
482 | args, kwargs = make_request.call_args
483 | self.assertEqual((expected_url,), args)
484 |
485 | def test_bodyweight(self):
486 | """
487 | Tests for retrieving body weight measurements.
488 | https://dev.fitbit.com/docs/body/#get-weight-logs
489 | Tests the following methods/URLs:
490 | GET https://api.fitbit.com/1/user/-/body/log/weight/date/1992-05-12.json
491 | GET https://api.fitbit.com/1/user/BAR/body/log/weight/date/1992-05-12/1998-12-31.json
492 | GET https://api.fitbit.com/1/user/BAR/body/log/weight/date/1992-05-12/1d.json
493 | GET https://api.fitbit.com/1/user/-/body/log/weight/date/2015-02-26.json
494 | """
495 | user_id = 'BAR'
496 |
497 | # No end_date or period
498 | self._test_get_bodyweight(
499 | base_date=datetime.date(1992, 5, 12), user_id=None, period=None,
500 | end_date=None,
501 | expected_url=URLBASE + "/-/body/log/weight/date/1992-05-12.json")
502 | # With end_date
503 | self._test_get_bodyweight(
504 | base_date=datetime.date(1992, 5, 12), user_id=user_id, period=None,
505 | end_date=datetime.date(1998, 12, 31),
506 | expected_url=URLBASE + "/BAR/body/log/weight/date/1992-05-12/1998-12-31.json")
507 | # With period
508 | self._test_get_bodyweight(
509 | base_date=datetime.date(1992, 5, 12), user_id=user_id, period="1d",
510 | end_date=None,
511 | expected_url=URLBASE + "/BAR/body/log/weight/date/1992-05-12/1d.json")
512 | # Date defaults to today
513 | today = datetime.date.today().strftime('%Y-%m-%d')
514 | self._test_get_bodyweight(
515 | base_date=None, user_id=None, period=None, end_date=None,
516 | expected_url=URLBASE + "/-/body/log/weight/date/%s.json" % today)
517 |
518 | def _test_get_bodyfat(self, base_date=None, user_id=None, period=None,
519 | end_date=None, expected_url=None):
520 | """ Helper method for testing getting bodyfat measurements """
521 | with mock.patch.object(self.fb, 'make_request') as make_request:
522 | self.fb.get_bodyfat(base_date, user_id=user_id, period=period,
523 | end_date=end_date)
524 | args, kwargs = make_request.call_args
525 | self.assertEqual((expected_url,), args)
526 |
527 | def test_bodyfat(self):
528 | """
529 | Tests for retrieving bodyfat measurements.
530 | https://dev.fitbit.com/docs/body/#get-body-fat-logs
531 | Tests the following methods/URLs:
532 | GET https://api.fitbit.com/1/user/-/body/log/fat/date/1992-05-12.json
533 | GET https://api.fitbit.com/1/user/BAR/body/log/fat/date/1992-05-12/1998-12-31.json
534 | GET https://api.fitbit.com/1/user/BAR/body/log/fat/date/1992-05-12/1d.json
535 | GET https://api.fitbit.com/1/user/-/body/log/fat/date/2015-02-26.json
536 | """
537 | user_id = 'BAR'
538 |
539 | # No end_date or period
540 | self._test_get_bodyfat(
541 | base_date=datetime.date(1992, 5, 12), user_id=None, period=None,
542 | end_date=None,
543 | expected_url=URLBASE + "/-/body/log/fat/date/1992-05-12.json")
544 | # With end_date
545 | self._test_get_bodyfat(
546 | base_date=datetime.date(1992, 5, 12), user_id=user_id, period=None,
547 | end_date=datetime.date(1998, 12, 31),
548 | expected_url=URLBASE + "/BAR/body/log/fat/date/1992-05-12/1998-12-31.json")
549 | # With period
550 | self._test_get_bodyfat(
551 | base_date=datetime.date(1992, 5, 12), user_id=user_id, period="1d",
552 | end_date=None,
553 | expected_url=URLBASE + "/BAR/body/log/fat/date/1992-05-12/1d.json")
554 | # Date defaults to today
555 | today = datetime.date.today().strftime('%Y-%m-%d')
556 | self._test_get_bodyfat(
557 | base_date=None, user_id=None, period=None, end_date=None,
558 | expected_url=URLBASE + "/-/body/log/fat/date/%s.json" % today)
559 |
560 | def test_friends(self):
561 | url = URLBASE + "/-/friends.json"
562 | self.common_api_test('get_friends', (), {}, (url,), {})
563 | url = URLBASE + "/FOOBAR/friends.json"
564 | self.common_api_test('get_friends', ("FOOBAR",), {}, (url,), {})
565 | url = URLBASE + "/-/friends/leaders/7d.json"
566 | self.common_api_test('get_friends_leaderboard', ("7d",), {}, (url,), {})
567 | url = URLBASE + "/-/friends/leaders/30d.json"
568 | self.common_api_test('get_friends_leaderboard', ("30d",), {}, (url,), {})
569 | self.verify_raises('get_friends_leaderboard', ("xd",), {}, ValueError)
570 |
571 | def test_invitations(self):
572 | url = URLBASE + "/-/friends/invitations.json"
573 | self.common_api_test('invite_friend', ("FOO",), {}, (url,), {'data': "FOO"})
574 | self.common_api_test('invite_friend_by_email', ("foo@bar",), {}, (url,), {'data':{'invitedUserEmail': "foo@bar"}})
575 | self.common_api_test('invite_friend_by_userid', ("foo@bar",), {}, (url,), {'data':{'invitedUserId': "foo@bar"}})
576 | url = URLBASE + "/-/friends/invitations/FOO.json"
577 | self.common_api_test('respond_to_invite', ("FOO", True), {}, (url,), {'data':{'accept': "true"}})
578 | self.common_api_test('respond_to_invite', ("FOO", False), {}, (url,), {'data':{'accept': "false"}})
579 | self.common_api_test('respond_to_invite', ("FOO", ), {}, (url,), {'data':{'accept': "true"}})
580 | self.common_api_test('accept_invite', ("FOO",), {}, (url,), {'data':{'accept': "true"}})
581 | self.common_api_test('reject_invite', ("FOO", ), {}, (url,), {'data':{'accept': "false"}})
582 |
583 | def test_alarms(self):
584 | url = "%s/-/devices/tracker/%s/alarms.json" % (URLBASE, 'FOO')
585 | self.common_api_test('get_alarms', (), {'device_id': 'FOO'}, (url,), {})
586 | url = "%s/-/devices/tracker/%s/alarms/%s.json" % (URLBASE, 'FOO', 'BAR')
587 | self.common_api_test('delete_alarm', (), {'device_id': 'FOO', 'alarm_id': 'BAR'}, (url,), {'method': 'DELETE'})
588 | url = "%s/-/devices/tracker/%s/alarms.json" % (URLBASE, 'FOO')
589 | self.common_api_test('add_alarm',
590 | (),
591 | {'device_id': 'FOO',
592 | 'alarm_time': datetime.datetime(year=2013, month=11, day=13, hour=8, minute=16),
593 | 'week_days': ['MONDAY']
594 | },
595 | (url,),
596 | {'data':
597 | {'enabled': True,
598 | 'recurring': False,
599 | 'time': datetime.datetime(year=2013, month=11, day=13, hour=8, minute=16).strftime("%H:%M%z"),
600 | 'vibe': 'DEFAULT',
601 | 'weekDays': ['MONDAY'],
602 | },
603 | 'method': 'POST'
604 | }
605 | )
606 | self.common_api_test('add_alarm',
607 | (),
608 | {'device_id': 'FOO',
609 | 'alarm_time': datetime.datetime(year=2013, month=11, day=13, hour=8, minute=16),
610 | 'week_days': ['MONDAY'], 'recurring': True, 'enabled': False, 'label': 'ugh',
611 | 'snooze_length': 5,
612 | 'snooze_count': 5
613 | },
614 | (url,),
615 | {'data':
616 | {'enabled': False,
617 | 'recurring': True,
618 | 'label': 'ugh',
619 | 'snoozeLength': 5,
620 | 'snoozeCount': 5,
621 | 'time': datetime.datetime(year=2013, month=11, day=13, hour=8, minute=16).strftime("%H:%M%z"),
622 | 'vibe': 'DEFAULT',
623 | 'weekDays': ['MONDAY'],
624 | },
625 | 'method': 'POST'}
626 | )
627 | url = "%s/-/devices/tracker/%s/alarms/%s.json" % (URLBASE, 'FOO', 'BAR')
628 | self.common_api_test('update_alarm',
629 | (),
630 | {'device_id': 'FOO',
631 | 'alarm_id': 'BAR',
632 | 'alarm_time': datetime.datetime(year=2013, month=11, day=13, hour=8, minute=16),
633 | 'week_days': ['MONDAY'], 'recurring': True, 'enabled': False, 'label': 'ugh',
634 | 'snooze_length': 5,
635 | 'snooze_count': 5
636 | },
637 | (url,),
638 | {'data':
639 | {'enabled': False,
640 | 'recurring': True,
641 | 'label': 'ugh',
642 | 'snoozeLength': 5,
643 | 'snoozeCount': 5,
644 | 'time': datetime.datetime(year=2013, month=11, day=13, hour=8, minute=16).strftime("%H:%M%z"),
645 | 'vibe': 'DEFAULT',
646 | 'weekDays': ['MONDAY'],
647 | },
648 | 'method': 'POST'}
649 | )
650 |
651 |
652 | class SubscriptionsTest(TestBase):
653 | """
654 | Class for testing the Fitbit Subscriptions API:
655 | https://dev.fitbit.com/docs/subscriptions/
656 | """
657 |
658 | def test_subscriptions(self):
659 | """
660 | Subscriptions tests. Tests the following methods/URLs:
661 | GET https://api.fitbit.com/1/user/-/apiSubscriptions.json
662 | GET https://api.fitbit.com/1/user/-/FOO/apiSubscriptions.json
663 | POST https://api.fitbit.com/1/user/-/apiSubscriptions/SUBSCRIPTION_ID.json
664 | POST https://api.fitbit.com/1/user/-/apiSubscriptions/SUBSCRIPTION_ID.json
665 | POST https://api.fitbit.com/1/user/-/COLLECTION/apiSubscriptions/SUBSCRIPTION_ID-COLLECTION.json
666 | """
667 | url = URLBASE + "/-/apiSubscriptions.json"
668 | self.common_api_test('list_subscriptions', (), {}, (url,), {})
669 | url = URLBASE + "/-/FOO/apiSubscriptions.json"
670 | self.common_api_test('list_subscriptions', ("FOO",), {}, (url,), {})
671 | url = URLBASE + "/-/apiSubscriptions/SUBSCRIPTION_ID.json"
672 | self.common_api_test('subscription', ("SUBSCRIPTION_ID", "SUBSCRIBER_ID"), {},
673 | (url,), {'method': 'POST', 'headers': {'X-Fitbit-Subscriber-id': "SUBSCRIBER_ID"}})
674 | self.common_api_test('subscription', ("SUBSCRIPTION_ID", "SUBSCRIBER_ID"), {'method': 'THROW'},
675 | (url,), {'method': 'THROW', 'headers': {'X-Fitbit-Subscriber-id': "SUBSCRIBER_ID"}})
676 | url = URLBASE + "/-/COLLECTION/apiSubscriptions/SUBSCRIPTION_ID-COLLECTION.json"
677 | self.common_api_test('subscription', ("SUBSCRIPTION_ID", "SUBSCRIBER_ID"), {'method': 'THROW', 'collection': "COLLECTION"},
678 | (url,), {'method': 'THROW', 'headers': {'X-Fitbit-Subscriber-id': "SUBSCRIBER_ID"}})
679 |
680 |
681 | class PartnerAPITest(TestBase):
682 | """
683 | Class for testing the Fitbit Partner API:
684 | https://dev.fitbit.com/docs/
685 | """
686 |
687 | def _test_intraday_timeseries(self, resource, base_date, detail_level,
688 | start_time, end_time, expected_url):
689 | """ Helper method for intraday timeseries tests """
690 | with mock.patch.object(self.fb, 'make_request') as make_request:
691 | retval = self.fb.intraday_time_series(
692 | resource, base_date, detail_level, start_time, end_time)
693 | args, kwargs = make_request.call_args
694 | self.assertEqual((expected_url,), args)
695 |
696 | def test_intraday_timeseries(self):
697 | """
698 | Intraday Time Series tests:
699 | https://dev.fitbit.com/docs/activity/#get-activity-intraday-time-series
700 |
701 | Tests the following methods/URLs:
702 | GET https://api.fitbit.com/1/user/-/FOO/date/1918-05-11/1d/1min.json
703 | GET https://api.fitbit.com/1/user/-/FOO/date/1918-05-11/1d/1min.json
704 | GET https://api.fitbit.com/1/user/-/FOO/date/1918-05-11/1d/1min/time/03:56/15:07.json
705 | GET https://api.fitbit.com/1/user/-/FOO/date/1918-05-11/1d/1min/time/3:56/15:07.json
706 | """
707 | resource = 'FOO'
708 | base_date = '1918-05-11'
709 |
710 | # detail_level must be valid
711 | self.assertRaises(
712 | ValueError,
713 | self.fb.intraday_time_series,
714 | resource,
715 | base_date,
716 | detail_level="xyz",
717 | start_time=None,
718 | end_time=None)
719 |
720 | # provide end_time if start_time provided
721 | self.assertRaises(
722 | TypeError,
723 | self.fb.intraday_time_series,
724 | resource,
725 | base_date,
726 | detail_level="1min",
727 | start_time='12:55',
728 | end_time=None)
729 | self.assertRaises(
730 | TypeError,
731 | self.fb.intraday_time_series,
732 | resource,
733 | base_date,
734 | detail_level="1min",
735 | start_time='12:55',
736 | end_time='')
737 |
738 | # provide start_time if end_time provided
739 | self.assertRaises(
740 | TypeError,
741 | self.fb.intraday_time_series,
742 | resource,
743 | base_date,
744 | detail_level="1min",
745 | start_time=None,
746 | end_time='12:55')
747 | self.assertRaises(
748 | TypeError,
749 | self.fb.intraday_time_series,
750 | resource,
751 | base_date,
752 | detail_level="1min",
753 | start_time='',
754 | end_time='12:55')
755 |
756 | # Default
757 | self._test_intraday_timeseries(
758 | resource, base_date=base_date, detail_level='1min',
759 | start_time=None, end_time=None,
760 | expected_url=URLBASE + "/-/FOO/date/1918-05-11/1d/1min.json")
761 | # start_date can be a date object
762 | self._test_intraday_timeseries(
763 | resource, base_date=datetime.date(1918, 5, 11),
764 | detail_level='1min', start_time=None, end_time=None,
765 | expected_url=URLBASE + "/-/FOO/date/1918-05-11/1d/1min.json")
766 | # start_time can be a datetime object
767 | self._test_intraday_timeseries(
768 | resource, base_date=base_date, detail_level='1min',
769 | start_time=datetime.time(3, 56), end_time='15:07',
770 | expected_url=URLBASE + "/-/FOO/date/1918-05-11/1d/1min/time/03:56/15:07.json")
771 | # end_time can be a datetime object
772 | self._test_intraday_timeseries(
773 | resource, base_date=base_date, detail_level='1min',
774 | start_time='3:56', end_time=datetime.time(15, 7),
775 | expected_url=URLBASE + "/-/FOO/date/1918-05-11/1d/1min/time/3:56/15:07.json")
776 | # start_time can be a midnight datetime object
777 | self._test_intraday_timeseries(
778 | resource, base_date=base_date, detail_level='1min',
779 | start_time=datetime.time(0, 0), end_time=datetime.time(15, 7),
780 | expected_url=URLBASE + "/-/FOO/date/1918-05-11/1d/1min/time/00:00/15:07.json")
781 | # end_time can be a midnight datetime object
782 | self._test_intraday_timeseries(
783 | resource, base_date=base_date, detail_level='1min',
784 | start_time=datetime.time(3, 56), end_time=datetime.time(0, 0),
785 | expected_url=URLBASE + "/-/FOO/date/1918-05-11/1d/1min/time/03:56/00:00.json")
786 | # start_time and end_time can be a midnight datetime object
787 | self._test_intraday_timeseries(
788 | resource, base_date=base_date, detail_level='1min',
789 | start_time=datetime.time(0, 0), end_time=datetime.time(0, 0),
790 | expected_url=URLBASE + "/-/FOO/date/1918-05-11/1d/1min/time/00:00/00:00.json")
791 |
--------------------------------------------------------------------------------
/fitbit/api.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | import datetime
3 | import json
4 | import requests
5 |
6 | try:
7 | from urllib.parse import urlencode
8 | except ImportError:
9 | # Python 2.x
10 | from urllib import urlencode
11 |
12 | from requests.auth import HTTPBasicAuth
13 | from requests_oauthlib import OAuth2Session
14 |
15 | from . import exceptions
16 | from .compliance import fitbit_compliance_fix
17 | from .utils import curry
18 |
19 |
20 | class FitbitOauth2Client(object):
21 | API_ENDPOINT = "https://api.fitbit.com"
22 | AUTHORIZE_ENDPOINT = "https://www.fitbit.com"
23 | API_VERSION = 1
24 |
25 | request_token_url = "%s/oauth2/token" % API_ENDPOINT
26 | authorization_url = "%s/oauth2/authorize" % AUTHORIZE_ENDPOINT
27 | access_token_url = request_token_url
28 | refresh_token_url = request_token_url
29 |
30 | def __init__(self, client_id, client_secret, access_token=None,
31 | refresh_token=None, expires_at=None, refresh_cb=None,
32 | redirect_uri=None, *args, **kwargs):
33 | """
34 | Create a FitbitOauth2Client object. Specify the first 7 parameters if
35 | you have them to access user data. Specify just the first 2 parameters
36 | to start the setup for user authorization (as an example see gather_key_oauth2.py)
37 | - client_id, client_secret are in the app configuration page
38 | https://dev.fitbit.com/apps
39 | - access_token, refresh_token are obtained after the user grants permission
40 | """
41 |
42 | self.client_id, self.client_secret = client_id, client_secret
43 | token = {}
44 | if access_token and refresh_token:
45 | token.update({
46 | 'access_token': access_token,
47 | 'refresh_token': refresh_token
48 | })
49 | if expires_at:
50 | token['expires_at'] = expires_at
51 | self.session = fitbit_compliance_fix(OAuth2Session(
52 | client_id,
53 | auto_refresh_url=self.refresh_token_url,
54 | token_updater=refresh_cb,
55 | token=token,
56 | redirect_uri=redirect_uri,
57 | ))
58 | self.timeout = kwargs.get("timeout", None)
59 |
60 | def _request(self, method, url, **kwargs):
61 | """
62 | A simple wrapper around requests.
63 | """
64 | if self.timeout is not None and 'timeout' not in kwargs:
65 | kwargs['timeout'] = self.timeout
66 |
67 | try:
68 | response = self.session.request(method, url, **kwargs)
69 |
70 | # If our current token has no expires_at, or something manages to slip
71 | # through that check
72 | if response.status_code == 401:
73 | d = json.loads(response.content.decode('utf8'))
74 | if d['errors'][0]['errorType'] == 'expired_token':
75 | self.refresh_token()
76 | response = self.session.request(method, url, **kwargs)
77 |
78 | return response
79 | except requests.Timeout as e:
80 | raise exceptions.Timeout(*e.args)
81 |
82 | def make_request(self, url, data=None, method=None, **kwargs):
83 | """
84 | Builds and makes the OAuth2 Request, catches errors
85 |
86 | https://dev.fitbit.com/docs/oauth2/#authorization-errors
87 | """
88 | data = data or {}
89 | method = method or ('POST' if data else 'GET')
90 | response = self._request(
91 | method,
92 | url,
93 | data=data,
94 | client_id=self.client_id,
95 | client_secret=self.client_secret,
96 | **kwargs
97 | )
98 |
99 | exceptions.detect_and_raise_error(response)
100 |
101 | return response
102 |
103 | def authorize_token_url(self, scope=None, redirect_uri=None, **kwargs):
104 | """Step 1: Return the URL the user needs to go to in order to grant us
105 | authorization to look at their data. Then redirect the user to that
106 | URL, open their browser to it, or tell them to copy the URL into their
107 | browser.
108 | - scope: pemissions that that are being requested [default ask all]
109 | - redirect_uri: url to which the response will posted. required here
110 | unless you specify only one Callback URL on the fitbit app or
111 | you already passed it to the constructor
112 | for more info see https://dev.fitbit.com/docs/oauth2/
113 | """
114 |
115 | self.session.scope = scope or [
116 | "activity",
117 | "nutrition",
118 | "heartrate",
119 | "location",
120 | "nutrition",
121 | "profile",
122 | "settings",
123 | "sleep",
124 | "social",
125 | "weight",
126 | ]
127 |
128 | if redirect_uri:
129 | self.session.redirect_uri = redirect_uri
130 |
131 | return self.session.authorization_url(self.authorization_url, **kwargs)
132 |
133 | def fetch_access_token(self, code, redirect_uri=None):
134 |
135 | """Step 2: Given the code from fitbit from step 1, call
136 | fitbit again and returns an access token object. Extract the needed
137 | information from that and save it to use in future API calls.
138 | the token is internally saved
139 | """
140 | if redirect_uri:
141 | self.session.redirect_uri = redirect_uri
142 | return self.session.fetch_token(
143 | self.access_token_url,
144 | username=self.client_id,
145 | password=self.client_secret,
146 | client_secret=self.client_secret,
147 | code=code)
148 |
149 | def refresh_token(self):
150 | """Step 3: obtains a new access_token from the the refresh token
151 | obtained in step 2. Only do the refresh if there is `token_updater(),`
152 | which saves the token.
153 | """
154 | token = {}
155 | if self.session.token_updater:
156 | token = self.session.refresh_token(
157 | self.refresh_token_url,
158 | auth=HTTPBasicAuth(self.client_id, self.client_secret)
159 | )
160 | self.session.token_updater(token)
161 |
162 | return token
163 |
164 |
165 | class Fitbit(object):
166 | """
167 | Before using this class, create a Fitbit app
168 | `here `_. There you will get the client id
169 | and secret needed to instantiate this class. When first authorizing a user,
170 | make sure to pass the `redirect_uri` keyword arg so fitbit will know where
171 | to return to when the authorization is complete. See
172 | `gather_keys_oauth2.py `_
173 | for a reference implementation of the authorization process. You should
174 | save ``access_token``, ``refresh_token``, and ``expires_at`` from the
175 | returned token for each user you authorize.
176 |
177 | When instantiating this class for use with an already authorized user, pass
178 | in the ``access_token``, ``refresh_token``, and ``expires_at`` keyword
179 | arguments. We also strongly recommend passing in a ``refresh_cb`` keyword
180 | argument, which should be a function taking one argument: a token dict.
181 | When that argument is present, we will automatically refresh the access
182 | token when needed and call this function so that you can save the updated
183 | token data. If you don't save the updated information, then you could end
184 | up with invalid access and refresh tokens, and the only way to recover from
185 | that is to reauthorize the user.
186 | """
187 | US = 'en_US'
188 | METRIC = 'en_UK'
189 |
190 | API_ENDPOINT = "https://api.fitbit.com"
191 | API_VERSION = 1
192 | WEEK_DAYS = ['SUNDAY', 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY']
193 | PERIODS = ['1d', '7d', '30d', '1w', '1m', '3m', '6m', '1y', 'max']
194 |
195 | RESOURCE_LIST = [
196 | 'body',
197 | 'activities',
198 | 'foods/log',
199 | 'foods/log/water',
200 | 'sleep',
201 | 'heart',
202 | 'bp',
203 | 'glucose',
204 | ]
205 |
206 | QUALIFIERS = [
207 | 'recent',
208 | 'favorite',
209 | 'frequent',
210 | ]
211 |
212 | def __init__(self, client_id, client_secret, access_token=None,
213 | refresh_token=None, expires_at=None, refresh_cb=None,
214 | redirect_uri=None, system=US, **kwargs):
215 | """
216 | Fitbit(, , access_token=, refresh_token=)
217 | """
218 | self.system = system
219 | self.client = FitbitOauth2Client(
220 | client_id,
221 | client_secret,
222 | access_token=access_token,
223 | refresh_token=refresh_token,
224 | expires_at=expires_at,
225 | refresh_cb=refresh_cb,
226 | redirect_uri=redirect_uri,
227 | **kwargs
228 | )
229 |
230 | # All of these use the same patterns, define the method for accessing
231 | # creating and deleting records once, and use curry to make individual
232 | # Methods for each
233 | for resource in Fitbit.RESOURCE_LIST:
234 | underscore_resource = resource.replace('/', '_')
235 | setattr(self, underscore_resource,
236 | curry(self._COLLECTION_RESOURCE, resource))
237 |
238 | if resource not in ['body', 'glucose']:
239 | # Body and Glucose entries are not currently able to be deleted
240 | setattr(self, 'delete_%s' % underscore_resource, curry(
241 | self._DELETE_COLLECTION_RESOURCE, resource))
242 |
243 | for qualifier in Fitbit.QUALIFIERS:
244 | setattr(self, '%s_activities' % qualifier, curry(self.activity_stats, qualifier=qualifier))
245 | setattr(self, '%s_foods' % qualifier, curry(self._food_stats,
246 | qualifier=qualifier))
247 |
248 | def make_request(self, *args, **kwargs):
249 | # This should handle data level errors, improper requests, and bad
250 | # serialization
251 | headers = kwargs.get('headers', {})
252 | headers.update({'Accept-Language': self.system})
253 | kwargs['headers'] = headers
254 |
255 | method = kwargs.get('method', 'POST' if 'data' in kwargs else 'GET')
256 | response = self.client.make_request(*args, **kwargs)
257 |
258 | if response.status_code == 202:
259 | return True
260 | if method == 'DELETE':
261 | if response.status_code == 204:
262 | return True
263 | else:
264 | raise exceptions.DeleteError(response)
265 | try:
266 | rep = json.loads(response.content.decode('utf8'))
267 | except ValueError:
268 | raise exceptions.BadResponse
269 |
270 | return rep
271 |
272 | def user_profile_get(self, user_id=None):
273 | """
274 | Get a user profile. You can get other user's profile information
275 | by passing user_id, or you can get the current user's by not passing
276 | a user_id
277 |
278 | .. note:
279 | This is not the same format that the GET comes back in, GET requests
280 | are wrapped in {'user': }
281 |
282 | https://dev.fitbit.com/docs/user/
283 | """
284 | url = "{0}/{1}/user/{2}/profile.json".format(*self._get_common_args(user_id))
285 | return self.make_request(url)
286 |
287 | def user_profile_update(self, data):
288 | """
289 | Set a user profile. You can set your user profile information by
290 | passing a dictionary of attributes that will be updated.
291 |
292 | .. note:
293 | This is not the same format that the GET comes back in, GET requests
294 | are wrapped in {'user': }
295 |
296 | https://dev.fitbit.com/docs/user/#update-profile
297 | """
298 | url = "{0}/{1}/user/-/profile.json".format(*self._get_common_args())
299 | return self.make_request(url, data)
300 |
301 | def _get_common_args(self, user_id=None):
302 | common_args = (self.API_ENDPOINT, self.API_VERSION,)
303 | if not user_id:
304 | user_id = '-'
305 | common_args += (user_id,)
306 | return common_args
307 |
308 | def _get_date_string(self, date):
309 | if not isinstance(date, str):
310 | return date.strftime('%Y-%m-%d')
311 | return date
312 |
313 | def _COLLECTION_RESOURCE(self, resource, date=None, user_id=None,
314 | data=None):
315 | """
316 | Retrieving and logging of each type of collection data.
317 |
318 | Arguments:
319 | resource, defined automatically via curry
320 | [date] defaults to today
321 | [user_id] defaults to current logged in user
322 | [data] optional, include for creating a record, exclude for access
323 |
324 | This implements the following methods::
325 |
326 | body(date=None, user_id=None, data=None)
327 | activities(date=None, user_id=None, data=None)
328 | foods_log(date=None, user_id=None, data=None)
329 | foods_log_water(date=None, user_id=None, data=None)
330 | sleep(date=None, user_id=None, data=None)
331 | heart(date=None, user_id=None, data=None)
332 | bp(date=None, user_id=None, data=None)
333 |
334 | * https://dev.fitbit.com/docs/
335 | """
336 |
337 | if not date:
338 | date = datetime.date.today()
339 | date_string = self._get_date_string(date)
340 |
341 | kwargs = {'resource': resource, 'date': date_string}
342 | if not data:
343 | base_url = "{0}/{1}/user/{2}/{resource}/date/{date}.json"
344 | else:
345 | data['date'] = date_string
346 | base_url = "{0}/{1}/user/{2}/{resource}.json"
347 | url = base_url.format(*self._get_common_args(user_id), **kwargs)
348 | return self.make_request(url, data)
349 |
350 | def _DELETE_COLLECTION_RESOURCE(self, resource, log_id):
351 | """
352 | deleting each type of collection data
353 |
354 | Arguments:
355 | resource, defined automatically via curry
356 | log_id, required, log entry to delete
357 |
358 | This builds the following methods::
359 |
360 | delete_body(log_id)
361 | delete_activities(log_id)
362 | delete_foods_log(log_id)
363 | delete_foods_log_water(log_id)
364 | delete_sleep(log_id)
365 | delete_heart(log_id)
366 | delete_bp(log_id)
367 |
368 | """
369 | url = "{0}/{1}/user/-/{resource}/{log_id}.json".format(
370 | *self._get_common_args(),
371 | resource=resource,
372 | log_id=log_id
373 | )
374 | response = self.make_request(url, method='DELETE')
375 | return response
376 |
377 | def _resource_goal(self, resource, data={}, period=None):
378 | """ Handles GETting and POSTing resource goals of all types """
379 | url = "{0}/{1}/user/-/{resource}/goal{postfix}.json".format(
380 | *self._get_common_args(),
381 | resource=resource,
382 | postfix=('s/' + period) if period else ''
383 | )
384 | return self.make_request(url, data=data)
385 |
386 | def _filter_nones(self, data):
387 | filter_nones = lambda item: item[1] is not None
388 | filtered_kwargs = list(filter(filter_nones, data.items()))
389 | return {} if not filtered_kwargs else dict(filtered_kwargs)
390 |
391 | def body_fat_goal(self, fat=None):
392 | """
393 | Implements the following APIs
394 |
395 | * https://dev.fitbit.com/docs/body/#get-body-goals
396 | * https://dev.fitbit.com/docs/body/#update-body-fat-goal
397 |
398 | Pass no arguments to get the body fat goal. Pass a ``fat`` argument
399 | to update the body fat goal.
400 |
401 | Arguments:
402 | * ``fat`` -- Target body fat in %; in the format X.XX
403 | """
404 | return self._resource_goal('body/log/fat', {'fat': fat} if fat else {})
405 |
406 | def body_weight_goal(self, start_date=None, start_weight=None, weight=None):
407 | """
408 | Implements the following APIs
409 |
410 | * https://dev.fitbit.com/docs/body/#get-body-goals
411 | * https://dev.fitbit.com/docs/body/#update-weight-goal
412 |
413 | Pass no arguments to get the body weight goal. Pass ``start_date``,
414 | ``start_weight`` and optionally ``weight`` to set the weight goal.
415 | ``weight`` is required if it hasn't been set yet.
416 |
417 | Arguments:
418 | * ``start_date`` -- Weight goal start date; in the format yyyy-MM-dd
419 | * ``start_weight`` -- Weight goal start weight; in the format X.XX
420 | * ``weight`` -- Weight goal target weight; in the format X.XX
421 | """
422 | data = self._filter_nones({
423 | 'startDate': start_date,
424 | 'startWeight': start_weight,
425 | 'weight': weight
426 | })
427 | if data and not ('startDate' in data and 'startWeight' in data):
428 | raise ValueError('start_date and start_weight are both required')
429 | return self._resource_goal('body/log/weight', data)
430 |
431 | def activities_daily_goal(self, calories_out=None, active_minutes=None,
432 | floors=None, distance=None, steps=None):
433 | """
434 | Implements the following APIs for period equal to daily
435 |
436 | https://dev.fitbit.com/docs/activity/#get-activity-goals
437 | https://dev.fitbit.com/docs/activity/#update-activity-goals
438 |
439 | Pass no arguments to get the daily activities goal. Pass any one of
440 | the optional arguments to set that component of the daily activities
441 | goal.
442 |
443 | Arguments:
444 | * ``calories_out`` -- New goal value; in an integer format
445 | * ``active_minutes`` -- New goal value; in an integer format
446 | * ``floors`` -- New goal value; in an integer format
447 | * ``distance`` -- New goal value; in the format X.XX or integer
448 | * ``steps`` -- New goal value; in an integer format
449 | """
450 | data = self._filter_nones({
451 | 'caloriesOut': calories_out,
452 | 'activeMinutes': active_minutes,
453 | 'floors': floors,
454 | 'distance': distance,
455 | 'steps': steps
456 | })
457 | return self._resource_goal('activities', data, period='daily')
458 |
459 | def activities_weekly_goal(self, distance=None, floors=None, steps=None):
460 | """
461 | Implements the following APIs for period equal to weekly
462 |
463 | https://dev.fitbit.com/docs/activity/#get-activity-goals
464 | https://dev.fitbit.com/docs/activity/#update-activity-goals
465 |
466 | Pass no arguments to get the weekly activities goal. Pass any one of
467 | the optional arguments to set that component of the weekly activities
468 | goal.
469 |
470 | Arguments:
471 | * ``distance`` -- New goal value; in the format X.XX or integer
472 | * ``floors`` -- New goal value; in an integer format
473 | * ``steps`` -- New goal value; in an integer format
474 | """
475 | data = self._filter_nones({'distance': distance, 'floors': floors,
476 | 'steps': steps})
477 | return self._resource_goal('activities', data, period='weekly')
478 |
479 | def food_goal(self, calories=None, intensity=None, personalized=None):
480 | """
481 | Implements the following APIs
482 |
483 | https://dev.fitbit.com/docs/food-logging/#get-food-goals
484 | https://dev.fitbit.com/docs/food-logging/#update-food-goal
485 |
486 | Pass no arguments to get the food goal. Pass at least ``calories`` or
487 | ``intensity`` and optionally ``personalized`` to update the food goal.
488 |
489 | Arguments:
490 | * ``calories`` -- Manual Calorie Consumption Goal; calories, integer;
491 | * ``intensity`` -- Food Plan intensity; (MAINTENANCE, EASIER, MEDIUM, KINDAHARD, HARDER);
492 | * ``personalized`` -- Food Plan type; ``True`` or ``False``
493 | """
494 | data = self._filter_nones({'calories': calories, 'intensity': intensity,
495 | 'personalized': personalized})
496 | if data and not ('calories' in data or 'intensity' in data):
497 | raise ValueError('Either calories or intensity is required')
498 | return self._resource_goal('foods/log', data)
499 |
500 | def water_goal(self, target=None):
501 | """
502 | Implements the following APIs
503 |
504 | https://dev.fitbit.com/docs/food-logging/#get-water-goal
505 | https://dev.fitbit.com/docs/food-logging/#update-water-goal
506 |
507 | Pass no arguments to get the water goal. Pass ``target`` to update it.
508 |
509 | Arguments:
510 | * ``target`` -- Target water goal in the format X.X, will be set in unit based on locale
511 | """
512 | data = self._filter_nones({'target': target})
513 | return self._resource_goal('foods/log/water', data)
514 |
515 | def time_series(self, resource, user_id=None, base_date='today',
516 | period=None, end_date=None):
517 | """
518 | The time series is a LOT of methods, (documented at urls below) so they
519 | don't get their own method. They all follow the same patterns, and
520 | return similar formats.
521 |
522 | Taking liberty, this assumes a base_date of today, the current user,
523 | and a 1d period.
524 |
525 | https://dev.fitbit.com/docs/activity/#activity-time-series
526 | https://dev.fitbit.com/docs/body/#body-time-series
527 | https://dev.fitbit.com/docs/food-logging/#food-or-water-time-series
528 | https://dev.fitbit.com/docs/heart-rate/#heart-rate-time-series
529 | https://dev.fitbit.com/docs/sleep/#sleep-time-series
530 | """
531 | if period and end_date:
532 | raise TypeError("Either end_date or period can be specified, not both")
533 |
534 | if end_date:
535 | end = self._get_date_string(end_date)
536 | else:
537 | if not period in Fitbit.PERIODS:
538 | raise ValueError("Period must be one of %s"
539 | % ','.join(Fitbit.PERIODS))
540 | end = period
541 |
542 | url = "{0}/{1}/user/{2}/{resource}/date/{base_date}/{end}.json".format(
543 | *self._get_common_args(user_id),
544 | resource=resource,
545 | base_date=self._get_date_string(base_date),
546 | end=end
547 | )
548 | return self.make_request(url)
549 |
550 | def intraday_time_series(self, resource, base_date='today', detail_level='1min', start_time=None, end_time=None):
551 | """
552 | The intraday time series extends the functionality of the regular time series, but returning data at a
553 | more granular level for a single day, defaulting to 1 minute intervals. To access this feature, one must
554 | fill out the Private Support form here (see https://dev.fitbit.com/docs/help/).
555 | For details on the resources available and more information on how to get access, see:
556 |
557 | https://dev.fitbit.com/docs/activity/#get-activity-intraday-time-series
558 | """
559 |
560 | # Check that the time range is valid
561 | time_test = lambda t: not (t is None or isinstance(t, str) and not t)
562 | time_map = list(map(time_test, [start_time, end_time]))
563 | if not all(time_map) and any(time_map):
564 | raise TypeError('You must provide both the end and start time or neither')
565 |
566 | """
567 | Per
568 | https://dev.fitbit.com/docs/activity/#get-activity-intraday-time-series
569 | the detail-level is now (OAuth 2.0 ):
570 | either "1min" or "15min" (optional). "1sec" for heart rate.
571 | """
572 | if not detail_level in ['1sec', '1min', '15min']:
573 | raise ValueError("Period must be either '1sec', '1min', or '15min'")
574 |
575 | url = "{0}/{1}/user/-/{resource}/date/{base_date}/1d/{detail_level}".format(
576 | *self._get_common_args(),
577 | resource=resource,
578 | base_date=self._get_date_string(base_date),
579 | detail_level=detail_level
580 | )
581 |
582 | if all(time_map):
583 | url = url + '/time'
584 | for time in [start_time, end_time]:
585 | time_str = time
586 | if not isinstance(time_str, str):
587 | time_str = time.strftime('%H:%M')
588 | url = url + ('/%s' % (time_str))
589 |
590 | url = url + '.json'
591 |
592 | return self.make_request(url)
593 |
594 | def activity_stats(self, user_id=None, qualifier=''):
595 | """
596 | * https://dev.fitbit.com/docs/activity/#activity-types
597 | * https://dev.fitbit.com/docs/activity/#get-favorite-activities
598 | * https://dev.fitbit.com/docs/activity/#get-recent-activity-types
599 | * https://dev.fitbit.com/docs/activity/#get-frequent-activities
600 |
601 | This implements the following methods::
602 |
603 | recent_activities(user_id=None, qualifier='')
604 | favorite_activities(user_id=None, qualifier='')
605 | frequent_activities(user_id=None, qualifier='')
606 | """
607 | if qualifier:
608 | if qualifier in Fitbit.QUALIFIERS:
609 | qualifier = '/%s' % qualifier
610 | else:
611 | raise ValueError("Qualifier must be one of %s"
612 | % ', '.join(Fitbit.QUALIFIERS))
613 | else:
614 | qualifier = ''
615 |
616 | url = "{0}/{1}/user/{2}/activities{qualifier}.json".format(
617 | *self._get_common_args(user_id),
618 | qualifier=qualifier
619 | )
620 | return self.make_request(url)
621 |
622 | def _food_stats(self, user_id=None, qualifier=''):
623 | """
624 | This builds the convenience methods on initialization::
625 |
626 | recent_foods(user_id=None, qualifier='')
627 | favorite_foods(user_id=None, qualifier='')
628 | frequent_foods(user_id=None, qualifier='')
629 |
630 | * https://dev.fitbit.com/docs/food-logging/#get-favorite-foods
631 | * https://dev.fitbit.com/docs/food-logging/#get-frequent-foods
632 | * https://dev.fitbit.com/docs/food-logging/#get-recent-foods
633 | """
634 | url = "{0}/{1}/user/{2}/foods/log/{qualifier}.json".format(
635 | *self._get_common_args(user_id),
636 | qualifier=qualifier
637 | )
638 | return self.make_request(url)
639 |
640 | def add_favorite_activity(self, activity_id):
641 | """
642 | https://dev.fitbit.com/docs/activity/#add-favorite-activity
643 | """
644 | url = "{0}/{1}/user/-/activities/favorite/{activity_id}.json".format(
645 | *self._get_common_args(),
646 | activity_id=activity_id
647 | )
648 | return self.make_request(url, method='POST')
649 |
650 | def log_activity(self, data):
651 | """
652 | https://dev.fitbit.com/docs/activity/#log-activity
653 | """
654 | url = "{0}/{1}/user/-/activities.json".format(*self._get_common_args())
655 | return self.make_request(url, data=data)
656 |
657 | def delete_favorite_activity(self, activity_id):
658 | """
659 | https://dev.fitbit.com/docs/activity/#delete-favorite-activity
660 | """
661 | url = "{0}/{1}/user/-/activities/favorite/{activity_id}.json".format(
662 | *self._get_common_args(),
663 | activity_id=activity_id
664 | )
665 | return self.make_request(url, method='DELETE')
666 |
667 | def add_favorite_food(self, food_id):
668 | """
669 | https://dev.fitbit.com/docs/food-logging/#add-favorite-food
670 | """
671 | url = "{0}/{1}/user/-/foods/log/favorite/{food_id}.json".format(
672 | *self._get_common_args(),
673 | food_id=food_id
674 | )
675 | return self.make_request(url, method='POST')
676 |
677 | def delete_favorite_food(self, food_id):
678 | """
679 | https://dev.fitbit.com/docs/food-logging/#delete-favorite-food
680 | """
681 | url = "{0}/{1}/user/-/foods/log/favorite/{food_id}.json".format(
682 | *self._get_common_args(),
683 | food_id=food_id
684 | )
685 | return self.make_request(url, method='DELETE')
686 |
687 | def create_food(self, data):
688 | """
689 | https://dev.fitbit.com/docs/food-logging/#create-food
690 | """
691 | url = "{0}/{1}/user/-/foods.json".format(*self._get_common_args())
692 | return self.make_request(url, data=data)
693 |
694 | def get_meals(self):
695 | """
696 | https://dev.fitbit.com/docs/food-logging/#get-meals
697 | """
698 | url = "{0}/{1}/user/-/meals.json".format(*self._get_common_args())
699 | return self.make_request(url)
700 |
701 | def get_devices(self):
702 | """
703 | https://dev.fitbit.com/docs/devices/#get-devices
704 | """
705 | url = "{0}/{1}/user/-/devices.json".format(*self._get_common_args())
706 | return self.make_request(url)
707 |
708 | def get_alarms(self, device_id):
709 | """
710 | https://dev.fitbit.com/docs/devices/#get-alarms
711 | """
712 | url = "{0}/{1}/user/-/devices/tracker/{device_id}/alarms.json".format(
713 | *self._get_common_args(),
714 | device_id=device_id
715 | )
716 | return self.make_request(url)
717 |
718 | def add_alarm(self, device_id, alarm_time, week_days, recurring=False,
719 | enabled=True, label=None, snooze_length=None,
720 | snooze_count=None, vibe='DEFAULT'):
721 | """
722 | https://dev.fitbit.com/docs/devices/#add-alarm
723 | alarm_time should be a timezone aware datetime object.
724 | """
725 | url = "{0}/{1}/user/-/devices/tracker/{device_id}/alarms.json".format(
726 | *self._get_common_args(),
727 | device_id=device_id
728 | )
729 | alarm_time = alarm_time.strftime("%H:%M%z")
730 | # Check week_days list
731 | if not isinstance(week_days, list):
732 | raise ValueError("Week days needs to be a list")
733 | for day in week_days:
734 | if day not in self.WEEK_DAYS:
735 | raise ValueError("Incorrect week day %s. see WEEK_DAY_LIST." % day)
736 | data = {
737 | 'time': alarm_time,
738 | 'weekDays': week_days,
739 | 'recurring': recurring,
740 | 'enabled': enabled,
741 | 'vibe': vibe
742 | }
743 | if label:
744 | data['label'] = label
745 | if snooze_length:
746 | data['snoozeLength'] = snooze_length
747 | if snooze_count:
748 | data['snoozeCount'] = snooze_count
749 | return self.make_request(url, data=data, method="POST")
750 | # return
751 |
752 | def update_alarm(self, device_id, alarm_id, alarm_time, week_days, recurring=False, enabled=True, label=None,
753 | snooze_length=None, snooze_count=None, vibe='DEFAULT'):
754 | """
755 | https://dev.fitbit.com/docs/devices/#update-alarm
756 | alarm_time should be a timezone aware datetime object.
757 | """
758 | # TODO Refactor with create_alarm. Tons of overlap.
759 | # Check week_days list
760 | if not isinstance(week_days, list):
761 | raise ValueError("Week days needs to be a list")
762 | for day in week_days:
763 | if day not in self.WEEK_DAYS:
764 | raise ValueError("Incorrect week day %s. see WEEK_DAY_LIST." % day)
765 | url = "{0}/{1}/user/-/devices/tracker/{device_id}/alarms/{alarm_id}.json".format(
766 | *self._get_common_args(),
767 | device_id=device_id,
768 | alarm_id=alarm_id
769 | )
770 | alarm_time = alarm_time.strftime("%H:%M%z")
771 |
772 | data = {
773 | 'time': alarm_time,
774 | 'weekDays': week_days,
775 | 'recurring': recurring,
776 | 'enabled': enabled,
777 | 'vibe': vibe
778 | }
779 | if label:
780 | data['label'] = label
781 | if snooze_length:
782 | data['snoozeLength'] = snooze_length
783 | if snooze_count:
784 | data['snoozeCount'] = snooze_count
785 | return self.make_request(url, data=data, method="POST")
786 | # return
787 |
788 | def delete_alarm(self, device_id, alarm_id):
789 | """
790 | https://dev.fitbit.com/docs/devices/#delete-alarm
791 | """
792 | url = "{0}/{1}/user/-/devices/tracker/{device_id}/alarms/{alarm_id}.json".format(
793 | *self._get_common_args(),
794 | device_id=device_id,
795 | alarm_id=alarm_id
796 | )
797 | return self.make_request(url, method="DELETE")
798 |
799 | def get_sleep(self, date):
800 | """
801 | https://dev.fitbit.com/docs/sleep/#get-sleep-logs
802 | date should be a datetime.date object.
803 | """
804 | url = "{0}/{1}/user/-/sleep/date/{year}-{month}-{day}.json".format(
805 | *self._get_common_args(),
806 | year=date.year,
807 | month=date.month,
808 | day=date.day
809 | )
810 | return self.make_request(url)
811 |
812 | def log_sleep(self, start_time, duration):
813 | """
814 | https://dev.fitbit.com/docs/sleep/#log-sleep
815 | start time should be a datetime object. We will be using the year, month, day, hour, and minute.
816 | """
817 | data = {
818 | 'startTime': start_time.strftime("%H:%M"),
819 | 'duration': duration,
820 | 'date': start_time.strftime("%Y-%m-%d"),
821 | }
822 | url = "{0}/{1}/user/-/sleep.json".format(*self._get_common_args())
823 | return self.make_request(url, data=data, method="POST")
824 |
825 | def activities_list(self):
826 | """
827 | https://dev.fitbit.com/docs/activity/#browse-activity-types
828 | """
829 | url = "{0}/{1}/activities.json".format(*self._get_common_args())
830 | return self.make_request(url)
831 |
832 | def activity_detail(self, activity_id):
833 | """
834 | https://dev.fitbit.com/docs/activity/#get-activity-type
835 | """
836 | url = "{0}/{1}/activities/{activity_id}.json".format(
837 | *self._get_common_args(),
838 | activity_id=activity_id
839 | )
840 | return self.make_request(url)
841 |
842 | def search_foods(self, query):
843 | """
844 | https://dev.fitbit.com/docs/food-logging/#search-foods
845 | """
846 | url = "{0}/{1}/foods/search.json?{encoded_query}".format(
847 | *self._get_common_args(),
848 | encoded_query=urlencode({'query': query})
849 | )
850 | return self.make_request(url)
851 |
852 | def food_detail(self, food_id):
853 | """
854 | https://dev.fitbit.com/docs/food-logging/#get-food
855 | """
856 | url = "{0}/{1}/foods/{food_id}.json".format(
857 | *self._get_common_args(),
858 | food_id=food_id
859 | )
860 | return self.make_request(url)
861 |
862 | def food_units(self):
863 | """
864 | https://dev.fitbit.com/docs/food-logging/#get-food-units
865 | """
866 | url = "{0}/{1}/foods/units.json".format(*self._get_common_args())
867 | return self.make_request(url)
868 |
869 | def get_bodyweight(self, base_date=None, user_id=None, period=None, end_date=None):
870 | """
871 | https://dev.fitbit.com/docs/body/#get-weight-logs
872 | base_date should be a datetime.date object (defaults to today),
873 | period can be '1d', '7d', '30d', '1w', '1m', '3m', '6m', '1y', 'max' or None
874 | end_date should be a datetime.date object, or None.
875 |
876 | You can specify period or end_date, or neither, but not both.
877 | """
878 | return self._get_body('weight', base_date, user_id, period, end_date)
879 |
880 | def get_bodyfat(self, base_date=None, user_id=None, period=None, end_date=None):
881 | """
882 | https://dev.fitbit.com/docs/body/#get-body-fat-logs
883 | base_date should be a datetime.date object (defaults to today),
884 | period can be '1d', '7d', '30d', '1w', '1m', '3m', '6m', '1y', 'max' or None
885 | end_date should be a datetime.date object, or None.
886 |
887 | You can specify period or end_date, or neither, but not both.
888 | """
889 | return self._get_body('fat', base_date, user_id, period, end_date)
890 |
891 | def _get_body(self, type_, base_date=None, user_id=None, period=None,
892 | end_date=None):
893 | if not base_date:
894 | base_date = datetime.date.today()
895 |
896 | if period and end_date:
897 | raise TypeError("Either end_date or period can be specified, not both")
898 |
899 | base_date_string = self._get_date_string(base_date)
900 |
901 | kwargs = {'type_': type_}
902 | base_url = "{0}/{1}/user/{2}/body/log/{type_}/date/{date_string}.json"
903 | if period:
904 | if not period in Fitbit.PERIODS:
905 | raise ValueError("Period must be one of %s" %
906 | ','.join(Fitbit.PERIODS))
907 | kwargs['date_string'] = '/'.join([base_date_string, period])
908 | elif end_date:
909 | end_string = self._get_date_string(end_date)
910 | kwargs['date_string'] = '/'.join([base_date_string, end_string])
911 | else:
912 | kwargs['date_string'] = base_date_string
913 |
914 | url = base_url.format(*self._get_common_args(user_id), **kwargs)
915 | return self.make_request(url)
916 |
917 | def get_friends(self, user_id=None):
918 | """
919 | https://dev.fitbit.com/docs/friends/#get-friends
920 | """
921 | url = "{0}/{1}/user/{2}/friends.json".format(*self._get_common_args(user_id))
922 | return self.make_request(url)
923 |
924 | def get_friends_leaderboard(self, period):
925 | """
926 | https://dev.fitbit.com/docs/friends/#get-friends-leaderboard
927 | """
928 | if not period in ['7d', '30d']:
929 | raise ValueError("Period must be one of '7d', '30d'")
930 | url = "{0}/{1}/user/-/friends/leaders/{period}.json".format(
931 | *self._get_common_args(),
932 | period=period
933 | )
934 | return self.make_request(url)
935 |
936 | def invite_friend(self, data):
937 | """
938 | https://dev.fitbit.com/docs/friends/#invite-friend
939 | """
940 | url = "{0}/{1}/user/-/friends/invitations.json".format(*self._get_common_args())
941 | return self.make_request(url, data=data)
942 |
943 | def invite_friend_by_email(self, email):
944 | """
945 | Convenience Method for
946 | https://dev.fitbit.com/docs/friends/#invite-friend
947 | """
948 | return self.invite_friend({'invitedUserEmail': email})
949 |
950 | def invite_friend_by_userid(self, user_id):
951 | """
952 | Convenience Method for
953 | https://dev.fitbit.com/docs/friends/#invite-friend
954 | """
955 | return self.invite_friend({'invitedUserId': user_id})
956 |
957 | def respond_to_invite(self, other_user_id, accept=True):
958 | """
959 | https://dev.fitbit.com/docs/friends/#respond-to-friend-invitation
960 | """
961 | url = "{0}/{1}/user/-/friends/invitations/{user_id}.json".format(
962 | *self._get_common_args(),
963 | user_id=other_user_id
964 | )
965 | accept = 'true' if accept else 'false'
966 | return self.make_request(url, data={'accept': accept})
967 |
968 | def accept_invite(self, other_user_id):
969 | """
970 | Convenience method for respond_to_invite
971 | """
972 | return self.respond_to_invite(other_user_id)
973 |
974 | def reject_invite(self, other_user_id):
975 | """
976 | Convenience method for respond_to_invite
977 | """
978 | return self.respond_to_invite(other_user_id, accept=False)
979 |
980 | def get_badges(self, user_id=None):
981 | """
982 | https://dev.fitbit.com/docs/friends/#badges
983 | """
984 | url = "{0}/{1}/user/{2}/badges.json".format(*self._get_common_args(user_id))
985 | return self.make_request(url)
986 |
987 | def subscription(self, subscription_id, subscriber_id, collection=None,
988 | method='POST'):
989 | """
990 | https://dev.fitbit.com/docs/subscriptions/
991 | """
992 | base_url = "{0}/{1}/user/-{collection}/apiSubscriptions/{end_string}.json"
993 | kwargs = {'collection': '', 'end_string': subscription_id}
994 | if collection:
995 | kwargs = {
996 | 'end_string': '-'.join([subscription_id, collection]),
997 | 'collection': '/' + collection
998 | }
999 | return self.make_request(
1000 | base_url.format(*self._get_common_args(), **kwargs),
1001 | method=method,
1002 | headers={"X-Fitbit-Subscriber-id": subscriber_id}
1003 | )
1004 |
1005 | def list_subscriptions(self, collection=''):
1006 | """
1007 | https://dev.fitbit.com/docs/subscriptions/#getting-a-list-of-subscriptions
1008 | """
1009 | url = "{0}/{1}/user/-{collection}/apiSubscriptions.json".format(
1010 | *self._get_common_args(),
1011 | collection='/{0}'.format(collection) if collection else ''
1012 | )
1013 | return self.make_request(url)
1014 |
--------------------------------------------------------------------------------