Skip to content

Commit

Permalink
Prepare for PyPi
Browse files Browse the repository at this point in the history
  • Loading branch information
audrow committed Jan 10, 2020
1 parent 340e897 commit 53e9744
Show file tree
Hide file tree
Showing 9 changed files with 252 additions and 63 deletions.
14 changes: 14 additions & 0 deletions .travis.yml
@@ -0,0 +1,14 @@
language: python
python:
- "3.6"
- "3.7"
- "3.8"
install:
- pip install .
- pip install pytest
- pip install pytest-cov
- pip install coveralls
script:
- pytest tests.py --cov=fitbit_client/
after_success:
- coveralls
7 changes: 7 additions & 0 deletions LICENSE
@@ -0,0 +1,7 @@
Copyright 2020 Audrow Nash

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
1 change: 1 addition & 0 deletions MANIFEST.in
@@ -0,0 +1 @@
include README.md LICENSE
33 changes: 33 additions & 0 deletions README.md
@@ -0,0 +1,33 @@
README
======

[![PyPI version](https://badge.fury.io/py/fitbit-client.svg)](https://badge.fury.io/py/fitbit-client)
[![Build Status](https://travis-ci.com/robotpt/fitbit-client.svg?branch=master)](https://travis-ci.com/robotpt/fitbit-client)
[![Coverage Status](https://coveralls.io/repos/github/robotpt/fitbit-client/badge.svg?branch=master)](https://coveralls.io/github/robotpt/fitbit-client?branch=master)

A Fitbit client that works from terminal to read steps data without launching a web browser or requiring that the user is signed in.

Features
--------
* No web sign-in is prompted when running the app
* Access doesn't expire
* Uses a one-time code to get the OAuth2 access token
* Saves a file with the credentials that it uses by default when run
* Exposes a function for generic requests to Fitbit's API
* Returns intraday steps data for a specified date in a Pandas Dataframe and the datetime of the last sync

Setup
-----

Follow the instructions [here](https://www.forkingbytes.com/blog/using-the-fitbit-api-from-a-command-line-application-in-go/) to obtain the following:
* Client ID
* Code
* Authorization

> Note that for access to intraday data, create a personal app.
Then, either
* Run this program and enter information when prompted
* Or pass this information as function arguments

You can then start making Fitbit API calls. The program supports intraday steps and the last sync time with convenience functions, and there is also a function `get_url` for making generic requests to Fitbit's API.
9 changes: 6 additions & 3 deletions example.py
@@ -1,8 +1,11 @@
from backend_fitbit_client import BackendFitbitClient
from fitbit_client import FitbitClient
import logging
import datetime

logging.basicConfig(level=logging.INFO)

fitbit_caller = BackendFitbitClient()
print(fitbit_caller.get_steps())
fitbit_caller = FitbitClient()
print(fitbit_caller.get_steps(
datetime.date(2020, 1, 1)
))
print(fitbit_caller.get_last_sync())
44 changes: 22 additions & 22 deletions backend_fitbit_client/__init__.py → fitbit_client/__init__.py
Expand Up @@ -17,7 +17,7 @@ class FitbitApiError(Exception):
pass


class BackendFitbitClient:
class FitbitClient:

class Oauth2Fields:
ACCESS_TOKEN = 'access_token'
Expand Down Expand Up @@ -136,21 +136,22 @@ def _init_oauth2_token(
self._scope = response[self.Oauth2Fields.SCOPE]
self._user_id = response[self.Oauth2Fields.USER_ID]

@staticmethod
def _get_error_message_from_get_response(response):
error_message = "\n"
for e in response['errors']:
error_message += "\t{}:\n".format(e["errorType"]) + textwrap.indent(
textwrap.fill(e['message']), prefix='\t\t'
) + "\n"
return error_message
def get_url(self, url):
headers = {"Authorization": "Bearer {}".format(self._access_token)}
logging.info("GET request to '{}'".format(url))
response = requests.get(
url=url,
headers=headers
).json()
self._check_response_for_errors(response)
return response

def get_last_sync(self):
url = "https://{api_url}/1/user/{user_id}/devices.json".format(
api_url=self.FitbitApi.API_URL,
user_id=self._user_id,
)
response = self._request_data(url)
response = self.get_url(url)
data_idx = 0
last_sync_str = response[data_idx][self.FitbitApi.LAST_SYNC_KEY]+"000" # zeros pad microseconds for parsing
str_format = "%Y-%m-%dT%H:%M:%S.%f"
Expand All @@ -164,9 +165,18 @@ def get_steps(self, date: datetime.date = None):
date=self._date_to_fitbit_date_string(date),
detail_level=self.FitbitApi.STEPS_DETAIL_LEVEL_1_MIN,
)
response = self._request_data(url)
response = self.get_url(url)
return self._steps_response_to_dataframe(response)

@staticmethod
def _get_error_message_from_get_response(response):
error_message = "\n"
for e in response['errors']:
error_message += "\t{}:\n".format(e["errorType"]) + textwrap.indent(
textwrap.fill(e['message']), prefix='\t\t'
) + "\n"
return error_message

@staticmethod
def _steps_response_to_dataframe(response):
times = []
Expand All @@ -176,16 +186,6 @@ def _steps_response_to_dataframe(response):
times.append(i['time'])
return pandas.DataFrame({'Time': times, 'Steps': steps})

def _request_data(self, url):
headers = {"Authorization": "Bearer {}".format(self._access_token)}
logging.info("GET request to '{}'".format(url))
response = requests.get(
url=url,
headers=headers
).json()
self._check_response_for_errors(response)
return response

@staticmethod
def _check_response_for_errors(response):
response_ = response.copy()
Expand All @@ -198,7 +198,7 @@ def _check_response_for_errors(response):
raise TypeError("response should be a dictionary or a list of dictionaries")
if 'success' in r.keys():
if r['success'] is False:
error_message = BackendFitbitClient._get_error_message_from_get_response(response)
error_message = FitbitClient._get_error_message_from_get_response(response)
logging.error(error_message, exc_info=True)
raise FitbitCredentialsError(error_message)

Expand Down
2 changes: 0 additions & 2 deletions requirements.txt
@@ -1,5 +1,3 @@
requests==2.22.0
oauthlib==3.1.0
requests_oauthlib==1.3.0
pyyaml==5.3
pandas==0.25.3
133 changes: 133 additions & 0 deletions setup.py
@@ -0,0 +1,133 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-

# Note: To use the 'upload' functionality of this file, you must:
# $ pipenv install twine --dev

import io
import os
import sys
from shutil import rmtree

from setuptools import find_packages, setup, Command

# Package meta-data.
NAME = 'fitbit-client'
DESCRIPTION = "Call Fitbit's web API from terminal"
URL = 'https://github.com/robotpt/fitbit-client'
EMAIL = 'audrow.nash@gmail.com'
AUTHOR = 'Audrow Nash'
REQUIRES_PYTHON = '>=3.6.0'
VERSION = '0.0.2'

# What packages are required for this module to be executed?
REQUIRED = [
'pandas',
'requests',
'pyyaml>=5.1',
]

# What packages are optional?
EXTRAS = {
# 'fancy feature': ['django'],
}

# The rest you shouldn't have to touch too much :)
# ------------------------------------------------
# Except, perhaps the License and Trove Classifiers!
# If you do change the License, remember to change the Trove Classifier for that!

here = os.path.abspath(os.path.dirname(__file__))

# Import the README and use it as the long-description.
# Note: this will only work if 'README.md' is present in your MANIFEST.in file!
try:
with io.open(os.path.join(here, 'README.md'), encoding='utf-8') as f:
long_description = '\n' + f.read()
except FileNotFoundError:
long_description = DESCRIPTION

# Load the package's __version__.py module as a dictionary.
about = {}
if not VERSION:
project_slug = NAME.lower().replace("-", "_").replace(" ", "_")
with open(os.path.join(here, project_slug, '__version__.py')) as f:
exec(f.read(), about)
else:
about['__version__'] = VERSION


class UploadCommand(Command):
"""Support setup.py upload."""

description = 'Build and publish the package.'
user_options = []

@staticmethod
def status(s):
"""Prints things in bold."""
print('\033[1m{0}\033[0m'.format(s))

def initialize_options(self):
pass

def finalize_options(self):
pass

def run(self):
try:
self.status('Removing previous builds…')
rmtree(os.path.join(here, 'dist'))
except OSError:
pass

self.status('Building Source and Wheel (universal) distribution…')
os.system('{0} setup.py sdist bdist_wheel --universal'.format(sys.executable))

self.status('Uploading the package to PyPI via Twine…')
os.system('twine upload dist/*')

self.status('Pushing git tags…')
os.system('git tag v{0}'.format(about['__version__']))
os.system('git push --tags')

sys.exit()


# Where the magic happens:
setup(
name=NAME,
version=about['__version__'],
description=DESCRIPTION,
long_description=long_description,
long_description_content_type='text/markdown',
author=AUTHOR,
author_email=EMAIL,
python_requires=REQUIRES_PYTHON,
url=URL,
packages=find_packages(exclude=["tests", "*.tests", "*.tests.*", "tests.*"]),
# If your package is a single module, use this instead of 'packages':
# py_modules=['mypackage'],

# entry_points={
# 'console_scripts': ['mycli=mymodule:cli'],
# },
install_requires=REQUIRED,
extras_require=EXTRAS,
include_package_data=True,
license='MIT',
classifiers=[
# Trove classifiers
# Full list: https://pypi.python.org/pypi?%3Aaction=list_classifiers
'License :: OSI Approved :: MIT License',
'Programming Language :: Python',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: Implementation :: CPython',
'Programming Language :: Python :: Implementation :: PyPy'
],
# $ setup.py publish support.
cmdclass={
'upload': UploadCommand,
},
)

0 comments on commit 53e9744

Please sign in to comment.