├── .gitignore ├── .travis.yml ├── AUTHORS ├── CHANGELOG.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── docs ├── Makefile ├── conf.py ├── index.rst └── make.bat ├── fitbit ├── __init__.py ├── api.py ├── compliance.py ├── exceptions.py └── utils.py ├── fitbit_tests ├── __init__.py ├── test_api.py ├── test_auth.py └── test_exceptions.py ├── gather_keys_oauth2.py ├── requirements ├── base.txt ├── dev.txt └── test.txt ├── setup.py └── tox.ini /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE AUTHORS README.rst requirements/* docs/* 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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_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_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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------