diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5dda794 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*~ +*.pyc +.ropeproject \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e06d208 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6db4117 --- /dev/null +++ b/Makefile @@ -0,0 +1,10 @@ + +venv: + virtualenv venv.garminexport + +init: + pip install -r requirements.txt + +clean: + find -name '*~' -exec rm {} \; + find -name '*pyc' -exec rm {} \; diff --git a/README.md b/README.md index 3e9cd03..157924c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,58 @@ garminexport ============ +The Garmin Connect activity exporter is a program that downloads all activities +for a given [Garmin Connect](http://connect.garmin.com/) account and stores them locally on the user's computer. -Garmin Connect activity exporter +Prerequisites +============= +The instructions below for running the program (or importing the module) +assumes that you have [Python 2.7](https://www.python.org/download/releases/2.7/), +[pip](http://pip.readthedocs.org/en/latest/installing.html), and [virtualenv](http://virtualenv.readthedocs.org/en/latest/virtualenv.html#installation) installed. + +It also assumes that you have registered an account at +[Garmin Connect](http://connect.garmin.com/) + +Getting started +=============== +Create and activate a new virtual environment to create an isolated development +environment (that contains the required dependencies and nothing else). + + `virtualenv venv.garminexport` + `. venv.garminexport/bin/activate` + +Install the required dependencies in this virtual environment: + + `pip install -r requirements.txt` + +Run the program +=============== +The program is run as follows (use the ``--help`` flag for a list of +available options). + + `./garminexport.py ` + +Once started, the program will prompt you for your account password and then +log in to your Garmin Connect account to download all activities to a destination +directory on your machine. + +For each activity, three files are stored: an activity summary (JSON), +activity details (JSON) and the activity GPX file. All files are written +to the same directory (``activities//`` by default). +Each activity file is prefixed by its upload timestamp and its activity id. + + +Library import +============== +To install the development version of this library in your local Python +environment, run: + + `pip install -e git://github.com/petergardfjall/garminexport.git#egg=garminexport` + +or if you prefer to use a `requirements.txt` file, add the following line +to your list of dependencies: + + `-e -e git://github.com/petergardfjall/garminexport.git#egg=garminexport` + +and run pip with you dependency file as input: + + `pip install -r requirements.txt` diff --git a/garminexport.py b/garminexport.py new file mode 100755 index 0000000..9098933 --- /dev/null +++ b/garminexport.py @@ -0,0 +1,93 @@ +#! /usr/bin/env python +"""A program that downloads all activities for a given Garmin Connect account +and stores them locally on the user's computer. +""" +import argparse +import codecs +from datetime import datetime +import getpass +from garminexport.garminclient import GarminClient +import io +import json +import logging +import os +import shutil +import sys +import traceback + +logging.basicConfig( + level=logging.INFO, format="%(asctime)-15s [%(levelname)s] %(message)s") +log = logging.getLogger(__name__) + +LOG_LEVELS = { + "DEBUG": logging.DEBUG, + "INFO": logging.INFO, + "WARNING": logging.WARNING, + "ERROR": logging.ERROR +} +"""Command-line (string-based) log-level mapping to logging module levels.""" + + +if __name__ == "__main__": + + parser = argparse.ArgumentParser( + description="Downloads all activities for a given Garmin Connect account.") + # positional args + parser.add_argument( + "username", metavar="", type=str, help="Account user name.") + # optional args + parser.add_argument( + "--password", type=str, help="Account password.") + parser.add_argument( + "--destination", metavar="DIR", type=str, + help=("Destination directory for downloaded activities. " + "Default: ./activities//"), + default=os.path.join(".", "activities", datetime.now().isoformat())) + parser.add_argument( + "--log-level", metavar="LEVEL", type=str, + help=("Desired log output level (DEBUG, INFO, WARNING, ERROR). " + "Default: INFO."), default="INFO") + + args = parser.parse_args() + if not args.log_level in LOG_LEVELS: + raise ValueError("Illegal log-level argument: {}".format(args.log_level)) + logging.root.setLevel(LOG_LEVELS[args.log_level]) + + try: + os.makedirs(args.destination) + if not args.password: + args.password = getpass.getpass("Enter password: ") + + with GarminClient(args.username, args.password) as client: + log.info("fetching activities for {} ...".format(args.username)) + activity_ids = client.list_activity_ids() + for index, id in enumerate(activity_ids): + log.info("processing activity {} out of {} ...".format( + index+1, len(activity_ids))) + activity_summary = client.get_activity_summary(id) + activity_details = client.get_activity_details(id) + activity_gpx = client.get_activity_gpx(id) + + # for each activitity save the summary, details and GPX file. + creation_millis = activity_summary["activity"]["uploadDate"]["millis"] + timestamp = datetime.fromtimestamp(int(creation_millis)/1000.0) + filename_prefix = "{}_{}".format( + timestamp.strftime("%Y%m%d-%H%M%S"), id) + path_prefix = os.path.join(args.destination, filename_prefix) + + summary_file = path_prefix + "_summary.json" + details_file = path_prefix + "_details.json" + gpx_file = path_prefix + ".gpx" + with codecs.open(summary_file, encoding="utf-8", mode="w") as f: + f.write(json.dumps( + activity_summary, ensure_ascii=False, indent=4)) + with codecs.open(details_file, encoding="utf-8", mode="w") as f: + f.write(json.dumps( + activity_details, ensure_ascii=False, indent=4)) + with codecs.open(gpx_file, encoding="utf-8", mode="w") as f: + f.write(activity_gpx) + except Exception as e: + exc_type, exc_value, exc_traceback = sys.exc_info() + log.error(u"failed with exception: %s", e) + raise + diff --git a/garminexport/__init__.py b/garminexport/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/garminexport/garminclient.py b/garminexport/garminclient.py new file mode 100755 index 0000000..ef23279 --- /dev/null +++ b/garminexport/garminclient.py @@ -0,0 +1,252 @@ +#! /usr/bin/env python +"""A module for authenticating against and communicating with selected +parts of the Garmin Connect REST API. +""" + +import json +import logging +import re +import requests +import sys + +# +# Note: For more detailed information about the API services +# used by this module, log in to your Garmin Connect account +# through the web browser and visit the API documentation page +# for the REST service of interest. For example: +# https://connect.garmin.com/proxy/activity-service-1.3/index.html +# https://connect.garmin.com/proxy/activity-search-service-1.2/index.html +# + +# +# Other useful references: +# https://github.com/cpfair/tapiriik/blob/master/tapiriik/services/GarminConnect/garminconnect.py +# https://forums.garmin.com/showthread.php?72150-connect-garmin-com-signin-question/page2 +# + +log = logging.getLogger(__name__) + +# reduce logging noise from requests library +logging.getLogger("requests").setLevel(logging.ERROR) + + +def require_session(client_function): + """Decorator that is used to annotate :class:`GarminClient` + methods that need an authenticated session before being called. + """ + def check_session(*args, **kwargs): + client_object = args[0] + if not client_object.session: + raise Exception("Attempt to use GarminClient without being connected. Call connect() before first use.'") + return client_function(*args, **kwargs) + return check_session + + +class GarminClient(object): + """A client class used to authenticate with Garmin Connect and + extract data from the user account. + + Since this class implements the context manager protocol, this object + can preferably be used together with the with-statement. This will + automatically take care of logging in to Garmin Connect before any + further interactions and logging out after the block completes or + a failure occurs. + + Example of use: :: + with GarminClient("my.sample@sample.com", "secretpassword") as client: + ids = client.list_activity_ids() + for activity_id in ids: + gpx = client.get_activity_gpx(activity_id) + + """ + + def __init__(self, username, password): + """Initialize a :class:`GarminClient` instance. + + :param username: Garmin Connect user name or email address. + :type username: str + :param password: Garmin Connect account password. + :type password: str + """ + self.username = username + self.password = password + self.session = None + + def __enter__(self): + self.connect() + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.disconnect() + + def connect(self): + self.session = requests.Session() + self._authenticate() + + def disconnect(self): + if self.session: + self.session.close() + self.session = None + + def _authenticate(self): + log.info("authenticating user ...") + params = { + "service": "http://connect.garmin.com/post-auth/login", + "clientId": "GarminConnect", + "consumeServiceTicket": "false" + } + flow_execution_key = self._get_flow_execution_key(params) + log.debug("flow execution key: '{}'".format(flow_execution_key)) + validation_url = self._get_auth_ticket(flow_execution_key, params) + log.debug("auth ticket validation url: {}".format(validation_url)) + self._validate_auth_ticket(validation_url) + + # referer seems to be a header that is required by the REST API + self.session.headers.update({'Referer': "https://some.random.site"}) + + def _get_flow_execution_key(self, request_params): + log.debug("get flow execution key ...") + response = self.session.get( + "https://sso.garmin.com/sso/login", params=request_params) + # parse out flowExecutionKey + flow_execution_key = re.search( + r'name="lt"\s+value="([^"]+)"', response.text).groups(1)[0] + return flow_execution_key + + def _get_auth_ticket(self, flow_execution_key, request_params): + data = { + "username": self.username, "password": self.password, + "_eventId": "submit", "embed": "true", "lt": flow_execution_key + } + log.debug("single sign-on ...") + sso_response = self.session.post( + "https://sso.garmin.com/sso/login", + params=request_params, data=data, allow_redirects=False) + # response must contain an SSO ticket + ticket_match = re.search("ticket=([^']+)'", sso_response.text) + if not ticket_match: + raise ValueError("failed to get authentication ticket: " + "did you enter valid credentials?") + ticket = ticket_match.group(1) + log.debug("SSO ticket: {}".format(ticket)) + # response should contain a URL where auth ticket can be validated + validation_url = re.search( + r"response_url\s+=\s+'([^']+)'", sso_response.text) + validation_url = validation_url.group(1) + return validation_url + + def _validate_auth_ticket(self, validation_url): + log.debug("validating authentication ticket ...") + response = self.session.get(validation_url, allow_redirects=True) + if not response.status_code == 200: + raise Exception( + u"failed to validate authentication ticket: {}:\n{}".format( + response.status_code, response.text)) + + + @require_session + def list_activity_ids(self): + """Return all activity ids stored by the logged in user. + + :returns: The full list of activity identifiers. + :rtype: list of str + """ + ids = [] + batch_size = 100 + # fetch in batches since the API doesn't allow more than a certain + # number of activities to be retrieved on every invocation + for start_index in xrange(0, sys.maxint, batch_size): + next_batch = self._fetch_activity_ids(start_index, batch_size) + if not next_batch: + break + ids.extend(next_batch) + return ids + + @require_session + def _fetch_activity_ids(self, start_index, max_limit=100): + """Return a sequence of activity ids starting at a given index, + with index 0 being the user's most recently registered activity. + + Should the index be out of bounds or the account empty, an empty + list is returned. + + :param start_index: The index of the first activity to retrieve. + :type start_index: int + :param max_limit: The (maximum) number of activities to retrieve. + :type max_limit: int + + :returns: A list of activity identifiers. + :rtype: list of str + """ + log.info("fetching activities {} through {} ...".format(start_index, start_index+max_limit-1)) + response = self.session.get( + "https://connect.garmin.com/proxy/activity-search-service-1.2/json/activities", params={"start": start_index, "limit": max_limit}) + if response.status_code != 200: + raise Exception( + u"failed to fetch activities {} to {} types: {}\n{}".format( + start_index, (start_index+max_limit-1), + response.status_code, response.text)) + results = json.loads(response.text)["results"] + if not "activities" in results: + # index out of bounds or empty account + return [] + entries = [int(entry["activity"]["activityId"]) for entry in results["activities"]] + log.debug("got {} activities.".format(len(entries))) + return entries + + @require_session + def get_activity_summary(self, activity_id): + """Return a summary about a given activity. The + summary contains several statistics, such as duration, GPS starting + point, GPS end point, elevation gain, max heart rate, max pace, max + speed, etc). + + :param activity_id: Activity identifier. + :type activity_id: int + :returns: The activity summary as a JSON dict. + :rtype: dict + """ + response = self.session.get("https://connect.garmin.com/proxy/activity-service-1.3/json/activity/{}".format(activity_id)) + if response.status_code != 200: + raise Exception(u"failed to fetch activity {}: {}\n{}".format( + activity_id, response.status_code, response.text)) + return json.loads(response.text) + + @require_session + def get_activity_details(self, activity_id): + """Return a JSON representation of a given activity including + available measurements such as location (longitude, latitude), + heart rate, distance, pace, speed, elevation. + + :param activity_id: Activity identifier. + :type activity_id: int + :returns: The activity details as a JSON dict. + :rtype: dict + """ + # mounted at xml or json depending on result encoding + response = self.session.get("https://connect.garmin.com/proxy/activity-service-1.3/json/activityDetails/{}".format(activity_id)) + if response.status_code != 200: + raise Exception(u"failed to fetch activity details for {}: {}\n{}".format( + activity_id, response.status_code, response.text)) + return json.loads(response.text) + + @require_session + def get_activity_gpx(self, activity_id): + """Return a GPX (GPS Exchange Format) representation of a + given activity. + + :param activity_id: Activity identifier. + :type activity_id: int + :returns: The GPX representation of the activity as an XML string. + :rtype: str + """ + response = self.session.get("https://connect.garmin.com/proxy/activity-service-1.3/gpx/course/{}".format(activity_id)) + # An alternate URL that seems to produce the same results + # and is the one used when exporting through the Garmin + # Connect web page. + #response = self.session.get("https://connect.garmin.com/proxy/activity-service-1.1/gpx/activity/{}?full=true".format(activity_id)) + if response.status_code != 200: + raise Exception(u"failed to fetch GPX for activity {}: {}\n{}".format( + activity_id, response.status_code, response.text)) + return response.text + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2f2d5f4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +requests==2.4.3 diff --git a/samples/lab.py b/samples/lab.py new file mode 100644 index 0000000..76fd088 --- /dev/null +++ b/samples/lab.py @@ -0,0 +1,39 @@ +#! /usr/bin/env python +""" +Script intended for Garmin Connect API experimenting in ipython. +un as: + ipython -i samples/lab.py -- --password= + +and use the client object (or client.session) to interact with +Garmin Connect. +""" + +import argparse +import getpass +from garminexport.garminclient import GarminClient +import json +import logging +import sys + +logging.basicConfig(level=logging.INFO, format="%(asctime)-15s [%(levelname)s] %(message)s") +log = logging.getLogger(__name__) + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + # positional args + parser.add_argument( + "username", metavar="", type=str, help="Account user name.") + # optional args + parser.add_argument( + "--password", type=str, help="Account password.") + + args = parser.parse_args() + print(args) + + if not args.password: + args.password = getpass.getpass("Enter password: ") + + client = GarminClient(args.username, args.password) + client.connect() + + print("client object ready for use.") diff --git a/samples/sample.py b/samples/sample.py new file mode 100755 index 0000000..17e7210 --- /dev/null +++ b/samples/sample.py @@ -0,0 +1,47 @@ +#! /usr/bin/env python + +import argparse +import getpass +from garminexport.garminclient import GarminClient +import json +import logging +import sys + +logging.basicConfig(level=logging.INFO, format="%(asctime)-15s [%(levelname)s] %(message)s") +log = logging.getLogger(__name__) + +if __name__ == "__main__": + + parser = argparse.ArgumentParser( + description="Export all Garmin Connect activities") + # positional args + parser.add_argument( + "username", metavar="", type=str, help="Account user name.") + # optional args + parser.add_argument( + "--password", type=str, help="Account password.") + + args = parser.parse_args() + print(args) + + if not args.password: + args.password = getpass.getpass("Enter password: ") + + try: + with GarminClient(args.username, args.password) as client: + log.info("activities:") + activity_ids = client.list_activity_ids() + log.info("num ids: {}".format(len(activity_ids))) + log.info(activity_ids) + + latest_activity = activity_ids[0] + activity = client.get_activity_summary(latest_activity) + log.info(u"activity id: %s", activity["activity"]["activityId"]) + log.info(u"activity name: '%s'", activity["activity"]["activityName"]) + log.info(u"activity description: '%s'", activity["activity"]["activityDescription"]) + log.info(json.dumps(client.get_activity_details(latest_activity), indent=4)) + log.info(client.get_activity_gpx(latest_activity)) + except Exception as e: + log.error(u"failed with exception: %s", e) + finally: + log.info("done") diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..5441f28 --- /dev/null +++ b/setup.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python + +"""Setup information for the Garmin Connect activity exporter.""" + +from setuptools import find_packages +from distutils.core import setup + +setup(name="Garmin Connect activity exporter", + version="1.0.0", + description=("A program that downloads all activities for a given Garmin Connect account and stores them locally on the user's computer."), + long_description=open('README.md').read(), + author="Peter GardfjÀll", + author_email="peter.gardfjall.work@gmail.com", + install_requires=open('requirements.txt').read(), + license=open('LICENSE').read(), + url="https://github.com/petergardfjall/garminexport", + packages=["garminexport"], + classifiers=[ + 'Development Status :: 4 - Beta', + 'Intended Audience :: Developers', + 'Intended Audience :: End Users/Desktop' + 'Natural Language :: English', + 'License :: OSI Approved :: Apache Software License', + 'Programming Language :: Python :: 2.7', + ])