diff --git a/.gitignore b/.gitignore index 04fff66..9303548 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,14 @@ *~ *.pyc .ropeproject +.coverage + +build/ /activities +*.egg-info/ +*.egg +*.py[cod] +__pycache__/ +*.so + +.venv/ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..af22b8e --- /dev/null +++ b/.travis.yml @@ -0,0 +1,8 @@ +language: python +python: + - "3.7" +install: + - pip install pipenv + - pipenv install +script: + - python setup.py test diff --git a/Makefile b/Makefile index 1864721..6bc441e 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,6 @@ -venv-py2: - virtualenv venv.garminexport - -venv-py3: - python3 -m venv venv.garminexport - -init: - pip install -r requirements.txt +venv: + pipenv install clean: find -name '*~' -exec rm {} \; diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..bf2d3c8 --- /dev/null +++ b/Pipfile @@ -0,0 +1,14 @@ +[[source]] +name = "pypi" +url = "https://pypi.org/simple" +verify_ssl = true + +[packages] +garminexport = {path = ".",editable = true} +requests = ">=2.0,<3" +python-dateutil = ">=2.0,<3" + +[dev-packages] +nose = "~=1.3" +coverage = "~=4.2" +mock = "~=2.0" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..f8bb937 --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,150 @@ +{ + "_meta": { + "hash": { + "sha256": "59e49afac1bfe0c2329a345793c399f46a2c57f14bf3abb6efa419c736c4009c" + }, + "pipfile-spec": 6, + "requires": {}, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "certifi": { + "hashes": [ + "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c", + "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830" + ], + "version": "==2020.12.5" + }, + "chardet": { + "hashes": [ + "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa", + "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==4.0.0" + }, + "garminexport": { + "editable": true, + "path": "." + }, + "idna": { + "hashes": [ + "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", + "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.10" + }, + "python-dateutil": { + "hashes": [ + "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", + "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a" + ], + "index": "pypi", + "version": "==2.8.1" + }, + "requests": { + "hashes": [ + "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804", + "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e" + ], + "index": "pypi", + "version": "==2.25.1" + }, + "six": { + "hashes": [ + "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", + "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.16.0" + }, + "urllib3": { + "hashes": [ + "sha256:2f4da4594db7e1e110a944bb1b551fdf4e6c136ad42e4234131391e21eb5b0df", + "sha256:e7b021f7241115872f92f43c6508082facffbd1c048e3c6e2bb9c2a157e28937" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", + "version": "==1.26.4" + } + }, + "develop": { + "coverage": { + "hashes": [ + "sha256:08907593569fe59baca0bf152c43f3863201efb6113ecb38ce7e97ce339805a6", + "sha256:0be0f1ed45fc0c185cfd4ecc19a1d6532d72f86a2bac9de7e24541febad72650", + "sha256:141f08ed3c4b1847015e2cd62ec06d35e67a3ac185c26f7635f4406b90afa9c5", + "sha256:19e4df788a0581238e9390c85a7a09af39c7b539b29f25c89209e6c3e371270d", + "sha256:23cc09ed395b03424d1ae30dcc292615c1372bfba7141eb85e11e50efaa6b351", + "sha256:245388cda02af78276b479f299bbf3783ef0a6a6273037d7c60dc73b8d8d7755", + "sha256:331cb5115673a20fb131dadd22f5bcaf7677ef758741312bee4937d71a14b2ef", + "sha256:386e2e4090f0bc5df274e720105c342263423e77ee8826002dcffe0c9533dbca", + "sha256:3a794ce50daee01c74a494919d5ebdc23d58873747fa0e288318728533a3e1ca", + "sha256:60851187677b24c6085248f0a0b9b98d49cba7ecc7ec60ba6b9d2e5574ac1ee9", + "sha256:63a9a5fc43b58735f65ed63d2cf43508f462dc49857da70b8980ad78d41d52fc", + "sha256:6b62544bb68106e3f00b21c8930e83e584fdca005d4fffd29bb39fb3ffa03cb5", + "sha256:6ba744056423ef8d450cf627289166da65903885272055fb4b5e113137cfa14f", + "sha256:7494b0b0274c5072bddbfd5b4a6c6f18fbbe1ab1d22a41e99cd2d00c8f96ecfe", + "sha256:826f32b9547c8091679ff292a82aca9c7b9650f9fda3e2ca6bf2ac905b7ce888", + "sha256:93715dffbcd0678057f947f496484e906bf9509f5c1c38fc9ba3922893cda5f5", + "sha256:9a334d6c83dfeadae576b4d633a71620d40d1c379129d587faa42ee3e2a85cce", + "sha256:af7ed8a8aa6957aac47b4268631fa1df984643f07ef00acd374e456364b373f5", + "sha256:bf0a7aed7f5521c7ca67febd57db473af4762b9622254291fbcbb8cd0ba5e33e", + "sha256:bf1ef9eb901113a9805287e090452c05547578eaab1b62e4ad456fcc049a9b7e", + "sha256:c0afd27bc0e307a1ffc04ca5ec010a290e49e3afbe841c5cafc5c5a80ecd81c9", + "sha256:dd579709a87092c6dbee09d1b7cfa81831040705ffa12a1b248935274aee0437", + "sha256:df6712284b2e44a065097846488f66840445eb987eb81b3cc6e4149e7b6982e1", + "sha256:e07d9f1a23e9e93ab5c62902833bf3e4b1f65502927379148b6622686223125c", + "sha256:e2ede7c1d45e65e209d6093b762e98e8318ddeff95317d07a27a2140b80cfd24", + "sha256:e4ef9c164eb55123c62411f5936b5c2e521b12356037b6e1c2617cef45523d47", + "sha256:eca2b7343524e7ba246cab8ff00cab47a2d6d54ada3b02772e908a45675722e2", + "sha256:eee64c616adeff7db37cc37da4180a3a5b6177f5c46b187894e633f088fb5b28", + "sha256:ef824cad1f980d27f26166f86856efe11eff9912c4fed97d3804820d43fa550c", + "sha256:efc89291bd5a08855829a3c522df16d856455297cf35ae827a37edac45f466a7", + "sha256:fa964bae817babece5aa2e8c1af841bebb6d0b9add8e637548809d040443fee0", + "sha256:ff37757e068ae606659c28c3bd0d923f9d29a85de79bf25b2b34b148473b5025" + ], + "index": "pypi", + "version": "==4.5.4" + }, + "mock": { + "hashes": [ + "sha256:5ce3c71c5545b472da17b72268978914d0252980348636840bd34a00b5cc96c1", + "sha256:b158b6df76edd239b8208d481dc46b6afd45a846b7812ff0ce58971cf5bc8bba" + ], + "index": "pypi", + "version": "==2.0.0" + }, + "nose": { + "hashes": [ + "sha256:9ff7c6cc443f8c51994b34a667bbcf45afd6d945be7477b52e97516fd17c53ac", + "sha256:dadcddc0aefbf99eea214e0f1232b94f2fa9bd98fa8353711dacb112bfcbbb2a", + "sha256:f1bffef9cbc82628f6e7d7b40d7e255aefaa1adb6a1b1d26c69a8b79e6208a98" + ], + "index": "pypi", + "version": "==1.3.7" + }, + "pbr": { + "hashes": [ + "sha256:42df03e7797b796625b1029c0400279c7c34fd7df24a7d7818a1abb5b38710dd", + "sha256:c68c661ac5cc81058ac94247278eeda6d2e6aecb3e227b0387c30d277e7ef8d4" + ], + "markers": "python_version >= '2.6'", + "version": "==5.6.0" + }, + "six": { + "hashes": [ + "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", + "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.16.0" + } + } +} diff --git a/README.md b/README.md index 0f8d57a..630d5b9 100644 --- a/README.md +++ b/README.md @@ -1,104 +1,122 @@ -Garmin Connect activity backup tool -=================================== -``garminbackup.py`` is a program that downloads activities for a -given [Garmin Connect](http://connect.garmin.com/) account and stores -them in a backup directory locally on the user's computer. The first time -the program is run, it will download *all* activities. After that, it will +[![Build Status](https://travis-ci.org/petergardfjall/garminexport.svg?branch=master)](https://travis-ci.org/petergardfjall/garminexport) +[![PyPi release](https://img.shields.io/pypi/v/garminexport.svg)](https://img.shields.io/pypi/v/garminexport.svg) +![PyPI - Python Version](https://img.shields.io/pypi/pyversions/garminexport) +![PyPI - License](https://img.shields.io/pypi/l/garminexport) + +# About +`garminexport` is both a library and a utility script for downloading/backing up +[Garmin Connect](http://connect.garmin.com/) activities to a local disk. + +The main utility script is called `garmin-backup` and performs incremental +backups of your Garmin account to a local directory. The first time +`garmin-backup` is run, it will download *all* activities. After that, it will do incremental backups of your account. That is, the script will only download activities that haven't already been downloaded to the backup directory. -The library contains a simple utility program, ``get_activity.py`` for -downloading a single Garmin Connect activity. Run ``./get_activity.py --help`` -for more details. -The library also contains a ``garminclient`` module that could be used by third-party -projects that need to communicate over the Garmin Connect API. See the -Library Import section below for more details. +# Installation +`garminexport` is available on [PyPi](https://pypi.org/) and can be installed +with [pip](http://pip.readthedocs.org): + + pip install garminexport + +It requires Python 3.5+. -Prerequisites -============= -The instructions below for running the program (or importing the module) -assumes that you have Python 2.7 or Python 3+, -[pip](http://pip.readthedocs.org/en/latest/installing.html), and -[virtualenv](http://virtualenv.readthedocs.org/en/latest/virtualenv.html#installation) -(not required with Python 3) installed. - -It also assumes that you have registered an account at -[Garmin Connect](http://connect.garmin.com/). +# Usage -Getting started -=============== -Create and activate a new virtual environment to create an isolated development -environment (that contains the required dependencies and nothing else). - - # using Python 2 - virtualenv venv.garminexport - - # using Python 3 - python -m venv venv.garminexport - -Activate the virtual environment - - . venv.garminexport/bin/activate - -Install the required dependencies in this virtual environment: - - pip install -r requirements.txt +## Prerequisites +To be of any use you need to register an account at [Garmin +Connect](http://connect.garmin.com/) and populate it with some activities. +## As a command-line tool (garmin-backup) -Running -======= -The backup program is run as follows (use the ``--help`` flag for a full list -of available options): +The backup program is run as follows (use the `--help` flag for a full list of +available options): - ./garminbackup.py --backup-dir=activities + garmin-backup --backup-dir=activities -Once started, the program will prompt you for your account password and then -log in to your Garmin Connect account to download activities to the specified -backup directory on your machine. The program will only download activities -that aren't already in the backup directory. +Once started, the program will prompt you for your account password and then log +in to your Garmin Connect account to download activities to the specified backup +directory on your machine. The program will only download activities that aren't +already in the backup directory. Activities can be exported in any of the formats outlined below. Note that by default, the program downloads all formats for every activity. Use the -``--format`` option to narrow the selection. +`--format` option to narrow the selection. Supported export formats: - - ``json_summary``: activity summary file (JSON) - - ``json_details``: activity details file (JSON) + - `gpx`: activity GPX file (XML). - - ``gpx``: activity GPX file (XML) + [GPX](https://en.wikipedia.org/wiki/GPS_Exchange_Format) is an open + format, mainly for storing GPS routes/tracks. It does support extensions + and Garmin appears to annotate the GPS data with, for example, heart-rate + and cadence, when available on your device. - - ``tcx``: an activity TCX file (XML). - *Note: a ``.tcx`` file may not always be possible to export, for example + - `tcx`: an activity TCX file (XML). + *Note: a `.tcx` file may not always be possible to export, for example if an activity was uploaded in gpx format. In that case, Garmin won't try to synthesize a tcx file.* - - ``fit``: activity FIT file (binary format). - *Note: a ``.fit`` file may not always be possible to export, for example + [TCX](https://en.wikipedia.org/wiki/Training_Center_XML) (Training + Center XML) is Garmin's own XML format. It is, essentially, an extension + of GPX which includes more metrics and divides the GPS track into "laps" + as recorded by your device (with "lap summaries" for each metric). + + - `fit`: activity FIT file (binary format). + *Note: a `.fit` file may not always be possible to export, for example if an activity was entered manually rather than imported from a Garmin device.* -All files are written to the same directory (``activities/`` by default). -Each activity file is prefixed by its upload timestamp and its activity id. + The [FIT](https://www.thisisant.com/resources/fit/) format is the + "raw data type" stored in your Garmin device and should contain all + metrics your device is capable of tracking (GPS, heart rate, cadence, + etc). It's a binary format, so tools are needed to read its content. + + - `json_summary`: activity summary file (JSON). + + Provides summary data for an activity. Seems to lack a formal schema + and should not be counted on as a stable data format (it may change at any + time). Only included since it *may* contain additional data that could be + useful for developers of analysis tools. + + - `json_details`: activity details file (JSON). + + Provides detailed activity data in a JSON format. Seems to lack a + formal schema and should not be counted on as a stable data format (it may + change at any time). Only included since it *may* contain additional data + that could be useful for developers of analysis tools. + +All files are written to the same directory (`activities/` by default). Each +activity file is prefixed by its upload timestamp and its activity id. +`garminexport` also contains a few smaller utility programs: -Library import -============== -To install the development version of this library in your local Python -environment, run: +- `garmin-get-activity`: download a single Garmin Connect activity. Run with + `--help`for more details. +- `garmin-upload-activity`: uplad a single Garmin Connect activity file (`.fit`, + `.gpx`, or `.tcx`). Run with `--help`for more details. - `pip install -e git://github.com/petergardfjall/garminexport.git#egg=garminexport` -If you prefer to use a `requirements.txt` file, add the following line -to your list of dependencies: +## As a library - `-e git://github.com/petergardfjall/garminexport.git#egg=garminexport` +To build your own tools around the Garmin Connect API you can import the +`garminclient` module. It handles authentication to establish a secure session +with Garmin Connect. For example use, have a look at the command-line tools +under [garminexport/cli](garminexport/cli). -and run pip with you dependency file as input: - `pip install -r requirements.txt` +# Contribute + +To work on the code base you need (besides the basic prerequisites outlined +above) to have [pipenv](https://github.com/pypa/pipenv) installed. Create a +`virtualenv` (an isolated development environment) and install the required +dependencies like so: + + + make venv + # or similarly: pipenv install diff --git a/garminbackup.py b/garminbackup.py deleted file mode 100755 index 82ef3e0..0000000 --- a/garminbackup.py +++ /dev/null @@ -1,124 +0,0 @@ -#! /usr/bin/env python -"""Performs (incremental) backups of activities for a given Garmin Connect -account. -The activities are stored in a local directory on the user's computer. -The backups are incremental, meaning that only activities that aren't already -stored in the backup directory will be downloaded. -""" -import argparse -from datetime import timedelta -import getpass -from garminexport.garminclient import GarminClient -import garminexport.backup -from garminexport.backup import export_formats -from garminexport.retryer import ( - Retryer, ExponentialBackoffDelayStrategy, MaxRetriesStopStrategy) -import logging -import os -import re -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.""" - -DEFAULT_MAX_RETRIES = 7 -"""The default maximum number of retries to make when fetching a single activity.""" - - -if __name__ == "__main__": - parser = argparse.ArgumentParser( - description=( - "Performs incremental backups of activities for a " - "given Garmin Connect account. Only activities that " - "aren't already stored in the backup directory will " - "be downloaded.")) - # 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( - "--backup-dir", metavar="DIR", type=str, - help=("Destination directory for downloaded activities. Default: " - "./activities/"), default=os.path.join(".", "activities")) - parser.add_argument( - "--log-level", metavar="LEVEL", type=str, - help=("Desired log output level (DEBUG, INFO, WARNING, ERROR). " - "Default: INFO."), default="INFO") - parser.add_argument( - "-f", "--format", choices=export_formats, - default=None, action='append', - help=("Desired output formats ("+', '.join(export_formats)+"). " - "Default: ALL.")) - parser.add_argument( - "-E", "--ignore-errors", action='store_true', - help="Ignore errors and keep going. Default: FALSE") - parser.add_argument( - "--max-retries", metavar="NUM", default=DEFAULT_MAX_RETRIES, - type=int, help="The maximum number of retries to make on failed attempts to fetch an activity. Exponential backoff will be used, meaning that the delay between successive attempts will double with every retry, starting at one second. DEFAULT: %d" % DEFAULT_MAX_RETRIES) - - args = parser.parse_args() - if not args.log_level in LOG_LEVELS: - raise ValueError("Illegal log-level: {}".format(args.log_level)) - - # if no --format was specified, all formats are to be backed up - args.format = args.format if args.format else export_formats - log.info("backing up formats: %s", ", ".join(args.format)) - - logging.root.setLevel(LOG_LEVELS[args.log_level]) - - try: - if not os.path.isdir(args.backup_dir): - os.makedirs(args.backup_dir) - - if not args.password: - args.password = getpass.getpass("Enter password: ") - - # set up a retryer that will handle retries of failed activity - # downloads - retryer = Retryer( - delay_strategy=ExponentialBackoffDelayStrategy( - initial_delay=timedelta(seconds=1)), - stop_strategy=MaxRetriesStopStrategy(args.max_retries)) - - - with GarminClient(args.username, args.password) as client: - # get all activity ids and timestamps from Garmin account - log.info("scanning activities for %s ...", args.username) - activities = set(retryer.call(client.list_activities)) - log.info("account has a total of %d activities", len(activities)) - - missing_activities = garminexport.backup.need_backup( - activities, args.backup_dir, args.format) - backed_up = activities - missing_activities - log.info("%s contains %d backed up activities", - args.backup_dir, len(backed_up)) - - log.info("activities that aren't backed up: %d", - len(missing_activities)) - - for index, activity in enumerate(missing_activities): - id, start = activity - log.info("backing up activity %d from %s (%d out of %d) ..." % (id, start, index+1, len(missing_activities))) - try: - garminexport.backup.download( - client, activity, retryer, args.backup_dir, - args.format) - except Exception as e: - log.error(u"failed with exception: %s", e) - if not args.ignore_errors: - raise - except Exception as e: - exc_type, exc_value, exc_traceback = sys.exc_info() - log.error(u"failed with exception: %s", str(e)) diff --git a/garminexport.pyproj b/garminexport.pyproj index d770c82..53468fd 100644 --- a/garminexport.pyproj +++ b/garminexport.pyproj @@ -11,10 +11,9 @@ . {888888a0-9f3d-457c-b088-3a5042f75d52} Standard Python launcher - + MSBuild|env|$(MSBuildProjectFullPath) False - - + markus@daeschler.name False @@ -44,5 +43,16 @@ + + + env + 3.7 + env (Python 3.7 (64-bit)) + Scripts\python.exe + Scripts\pythonw.exe + PYTHONPATH + X64 + + \ No newline at end of file diff --git a/garminexport/backup.py b/garminexport/backup.py index 18b49ee..86348a2 100644 --- a/garminexport/backup.py +++ b/garminexport/backup.py @@ -2,14 +2,13 @@ """ import codecs import json -from datetime import datetime -import dateutil.parser import logging import os +from datetime import datetime log = logging.getLogger(__name__) -export_formats=["json_summary", "json_details", "gpx", "tcx", "fit"] +supported_export_formats = ["json_summary", "json_details", "gpx", "tcx", "fit"] """The range of supported export formats for activities.""" format_suffix = { @@ -21,7 +20,6 @@ format_suffix = { } """A table that maps export formats to their file format extensions.""" - not_found_file = ".not_found" """A file that lists all tried but failed export attempts. The lines in the file are the would-have-been file names, had the exports been successful. @@ -49,7 +47,7 @@ def export_filename(activity, export_format): id=activity[0], time=activity[1].isoformat(), suffix=format_suffix[export_format]) - return fn.replace(':','_') if os.name=='nt' else fn + return fn.replace(':', '_') if os.name == 'nt' else fn def need_backup(activities, backup_dir, export_formats=None): @@ -65,6 +63,9 @@ def need_backup(activities, backup_dir, export_formats=None): :type activities: list of tuples of `(int, datetime)` :param backup_dir: Destination directory for exported activities. :type backup_dir: str + :keyword export_formats: Which format(s) to export to. Could be any + of: 'json_summary', 'json_details', 'gpx', 'tcx', 'fit'. + :type export_formats: list of str :return: All activities that need to be backed up. :rtype: set of tuples of `(int, datetime)` """ @@ -87,12 +88,10 @@ def _not_found_activities(backup_dir): if os.path.isfile(_not_found): with open(_not_found, mode="r") as f: failed_activities = [line.strip() for line in f.readlines()] - log.debug("%d tried but failed activities in %s", - len(failed_activities), _not_found) + log.debug("%d tried but failed activities in %s", len(failed_activities), _not_found) return failed_activities - def download(client, activity, retryer, backup_dir, export_formats=None): """Exports a Garmin Connect activity to a given set of formats and saves the resulting file(s) to a given backup directory. @@ -124,25 +123,21 @@ def download(client, activity, retryer, backup_dir, export_formats=None): dest = os.path.join( backup_dir, export_filename(activity, 'json_summary')) with codecs.open(dest, encoding="utf-8", mode="w") as f: - f.write(json.dumps( - activity_summary, ensure_ascii=False, indent=4)) + f.write(json.dumps(activity_summary, ensure_ascii=False, indent=4)) if 'json_details' in export_formats: log.debug("getting json details for %s", id) activity_details = retryer.call(client.get_activity_details, id) - dest = os.path.join( - backup_dir, export_filename(activity, 'json_details')) + dest = os.path.join(backup_dir, export_filename(activity, 'json_details')) with codecs.open(dest, encoding="utf-8", mode="w") as f: - f.write(json.dumps( - activity_details, ensure_ascii=False, indent=4)) + f.write(json.dumps(activity_details, ensure_ascii=False, indent=4)) not_found_path = os.path.join(backup_dir, not_found_file) with open(not_found_path, mode="a") as not_found: if 'gpx' in export_formats: log.debug("getting gpx for %s", id) activity_gpx = retryer.call(client.get_activity_gpx, id) - dest = os.path.join( - backup_dir, export_filename(activity, 'gpx')) + dest = os.path.join(backup_dir, export_filename(activity, 'gpx')) if activity_gpx is None: not_found.write(os.path.basename(dest) + "\n") else: @@ -152,8 +147,7 @@ def download(client, activity, retryer, backup_dir, export_formats=None): if 'tcx' in export_formats: log.debug("getting tcx for %s", id) activity_tcx = retryer.call(client.get_activity_tcx, id) - dest = os.path.join( - backup_dir, export_filename(activity, 'tcx')) + dest = os.path.join(backup_dir, export_filename(activity, 'tcx')) if activity_tcx is None: not_found.write(os.path.basename(dest) + "\n") else: diff --git a/garminexport/cli/__init__.py b/garminexport/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/garminexport/cli/backup.py b/garminexport/cli/backup.py new file mode 100644 index 0000000..66a563e --- /dev/null +++ b/garminexport/cli/backup.py @@ -0,0 +1,87 @@ +"""This script performs backups of activities for a Garmin Connect account. The +activities are stored in a local directory on the user's computer. The backups +are incremental, meaning that only activities that aren't already stored in the +backup directory will be downloaded. + +""" +import argparse +import logging +import os + +from garminexport.backup import supported_export_formats +from garminexport.incremental_backup import incremental_backup +from garminexport.logging_config import LOG_LEVELS + +logging.basicConfig(level=logging.INFO, format="%(asctime)-15s [%(levelname)s] %(message)s") +log = logging.getLogger(__name__) + +DEFAULT_MAX_RETRIES = 7 +"""The default maximum number of retries to make when fetching a single activity.""" + +DEFAULT_USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36' +"""The default `User-Agent` to use for HTTP requests when none is supplied by +the user. +""" + +def parse_args() -> argparse.Namespace: + """Parse CLI arguments. + + :return: Namespace object holding parsed arguments as attributes. + This object may be directly used by garminexport/garminbackup.py. + """ + parser = argparse.ArgumentParser( + prog="garminbackup", + description=( + "Performs incremental backups of activities for a " + "given Garmin Connect account. Only activities that " + "aren't already stored in the backup directory will " + "be downloaded.")) + # 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( + "--backup-dir", metavar="DIR", type=str, + help="Destination directory for downloaded activities. Default: ./activities/", + default=os.path.join(".", "activities")) + parser.add_argument( + "--log-level", metavar="LEVEL", type=str, + help="Desired log output level (DEBUG, INFO, WARNING, ERROR). Default: INFO.", + default="INFO") + parser.add_argument( + "-f", "--format", choices=supported_export_formats, + default=None, action='append', + help="Desired output formats ({}). Default: ALL.".format(', '.join(supported_export_formats))) + parser.add_argument( + "-E", "--ignore-errors", action='store_true', + help="Ignore errors and keep going. Default: FALSE") + parser.add_argument( + "--max-retries", metavar="NUM", default=DEFAULT_MAX_RETRIES, + type=int, + help=("The maximum number of retries to make on failed attempts to fetch an activity. " + "Exponential backoff will be used, meaning that the delay between successive attempts " + "will double with every retry, starting at one second. DEFAULT: {}").format(DEFAULT_MAX_RETRIES)) + parser.add_argument( + "--user-agent", type=str, default=DEFAULT_USER_AGENT, + help="A value to use for the `User-Agent` request header. Use an authentic browser agent string to prevent being blocked by Garmin. A tool such as `user_agent` (`ua`) can be used to generate such values.") + + return parser.parse_args() + + +def main(): + args = parse_args() + logging.root.setLevel(LOG_LEVELS[args.log_level]) + + try: + incremental_backup(username=args.username, + password=args.password, + user_agent_fn=lambda:DEFAULT_USER_AGENT, + backup_dir=args.backup_dir, + export_formats=args.format, + ignore_errors=args.ignore_errors, + max_retries=args.max_retries) + + except Exception as e: + log.error("failed with exception: {}".format(e)) diff --git a/garminexport/cli/get_activity.py b/garminexport/cli/get_activity.py new file mode 100755 index 0000000..c7ceff9 --- /dev/null +++ b/garminexport/cli/get_activity.py @@ -0,0 +1,79 @@ +#! /usr/bin/env python +"""A program that downloads one particular activity from a given Garmin +Connect account and stores it locally on the user's computer. +""" +import argparse +import getpass +import logging +import os +from datetime import timedelta + +import dateutil.parser + +import garminexport.backup +from garminexport.garminclient import GarminClient +from garminexport.logging_config import LOG_LEVELS +from garminexport.retryer import Retryer, ExponentialBackoffDelayStrategy, MaxRetriesStopStrategy + +logging.basicConfig(level=logging.INFO, format="%(asctime)-15s [%(levelname)s] %(message)s") +log = logging.getLogger(__name__) + + +def main(): + parser = argparse.ArgumentParser( + description="Downloads one particular activity for a given Garmin Connect account.") + + # positional args + parser.add_argument( + "username", metavar="", type=str, help="Account user name.") + parser.add_argument( + "activity", metavar="", type=int, help="Activity ID.") + parser.add_argument( + "format", metavar="", type=str, + help="Export format (one of: {}).".format(garminexport.backup.supported_export_formats)) + + # optional args + parser.add_argument( + "--password", type=str, help="Account password.") + parser.add_argument( + "--destination", metavar="DIR", type=str, + help="Destination directory for downloaded activity. Default: ./activities/", + default=os.path.join(".", "activities")) + 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 args.log_level not in LOG_LEVELS: + raise ValueError("Illegal log-level argument: {}".format(args.log_level)) + + if args.format not in garminexport.backup.supported_export_formats: + raise ValueError( + "Unrecognized export format: '{}'. Must be one of {}".format( + args.format, garminexport.backup.supported_export_formats)) + + logging.root.setLevel(LOG_LEVELS[args.log_level]) + + try: + if not os.path.isdir(args.destination): + 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 activity %s ...", args.activity) + summary = client.get_activity_summary(args.activity) + # set up a retryer that will handle retries of failed activity downloads + retryer = Retryer( + delay_strategy=ExponentialBackoffDelayStrategy(initial_delay=timedelta(seconds=1)), + stop_strategy=MaxRetriesStopStrategy(5)) + + start_time = dateutil.parser.parse(summary["summaryDTO"]["startTimeGMT"]) + garminexport.backup.download( + client, (args.activity, start_time), retryer, args.destination, export_formats=[args.format]) + except Exception as e: + log.error("failed with exception: %s", e) + raise diff --git a/upload_activity.py b/garminexport/cli/upload_activity.py similarity index 62% rename from upload_activity.py rename to garminexport/cli/upload_activity.py index 37f2da0..1d6d69e 100755 --- a/upload_activity.py +++ b/garminexport/cli/upload_activity.py @@ -1,30 +1,21 @@ #! /usr/bin/env python -"""A program that uploads an activity file to a Garmin -Connect account. +"""A program that uploads an activity file to a Garmin Connect account. """ import argparse import getpass -from garminexport.garminclient import GarminClient import logging -import sys -import traceback -logging.basicConfig( - level=logging.INFO, format="%(asctime)-15s [%(levelname)s] %(message)s") +from garminexport.garminclient import GarminClient +from garminexport.logging_config import LOG_LEVELS + +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__": +def main(): parser = argparse.ArgumentParser( - description=("Uploads an activity file to a Garmin Connect account.")) + description="Uploads an activity file to a Garmin Connect account.") + # positional args parser.add_argument( "username", metavar="", type=str, help="Account user name.") @@ -45,31 +36,34 @@ if __name__ == "__main__": '-T', '--type', help="Override activity type (running, cycling, walking, hiking, strength_training, etc.)") parser.add_argument( "--log-level", metavar="LEVEL", type=str, - help=("Desired log output level (DEBUG, INFO, WARNING, ERROR). " - "Default: INFO."), default="INFO") + help="Desired log output level (DEBUG, INFO, WARNING, ERROR). Default: INFO.", + default="INFO") args = parser.parse_args() - if len(args.activity)>1 and (args.description is not None or args.name is not None): + + if len(args.activity) > 1 and (args.description is not None or args.name is not None): parser.error("When uploading multiple activities, --name or --description cannot be used.") - if not args.log_level in LOG_LEVELS: - raise ValueError("Illegal log-level argument: {}".format( - args.log_level)) + + if args.log_level not in LOG_LEVELS: + raise ValueError("Illegal log-level argument: {}".format(args.log_level)) + logging.root.setLevel(LOG_LEVELS[args.log_level]) try: if not args.password: args.password = getpass.getpass("Enter password: ") + with GarminClient(args.username, args.password) as client: for activity in args.activity: - log.info("uploading activity file {} ...".format(activity.name)) + log.info("uploading activity file %s ...", activity.name) try: - id = client.upload_activity(activity, name=args.name, description=args.description, private=args.private, activity_type=args.type) + id = client.upload_activity(activity, name=args.name, description=args.description, + private=args.private, activity_type=args.type) except Exception as e: log.error("upload failed: {!r}".format(e)) else: - log.info("upload successful: https://connect.garmin.com/modern/activity/{}".format(id)) - except Exception as e: - exc_type, exc_value, exc_traceback = sys.exc_info() - log.error(u"failed with exception: %s", e) - raise + log.info("upload successful: https://connect.garmin.com/modern/activity/%s", id) + except Exception as e: + log.error("failed with exception: %s", e) + raise diff --git a/garminexport/garminclient.py b/garminexport/garminclient.py index 8fcad4b..e221009 100755 --- a/garminexport/garminclient.py +++ b/garminexport/garminclient.py @@ -6,16 +6,20 @@ parts of the Garmin Connect REST API. import json import logging import os +import os.path import re -import requests -from io import BytesIO import sys import zipfile +from datetime import timedelta, datetime +from builtins import range +from functools import wraps +from io import BytesIO + import dateutil import dateutil.parser -import os.path -from functools import wraps -from builtins import range +import requests + +from garminexport.retryer import Retryer, ExponentialBackoffDelayStrategy, MaxRetriesStopStrategy # # Note: For more detailed information about the API services @@ -37,20 +41,25 @@ log = logging.getLogger(__name__) # reduce logging noise from requests library logging.getLogger("requests").setLevel(logging.ERROR) -SSO_LOGIN_URL = "https://sso.garmin.com/sso/signin" -"""The Garmin Connect Single-Sign On login URL.""" +SSO_LOGIN_URL = "https://sso.garmin.com/sso/login" +"""Garmin Connect's Single-Sign On login URL.""" +SSO_SIGNIN_URL = "https://sso.garmin.com/sso/signin" +"""The Garmin Connect Single-Sign On sign-in URL. This is where the login form +gets POSTed.""" def require_session(client_function): """Decorator that is used to annotate :class:`GarminClient` methods that need an authenticated session before being called. """ + @wraps(client_function) 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 @@ -72,18 +81,27 @@ class GarminClient(object): """ - def __init__(self, username, password): + def __init__(self, username, password, user_agent_fn=None): """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 + :keyword user_agent_fn: A function that, when called, produces a + `User-Agent` string to be used as `User-Agent` for the remainder of the + session. If set to None, the default user agent of the http request + library is used. + :type user_agent_fn: Callable[[], str] + """ self.username = username self.password = password + self._user_agent_fn = user_agent_fn + self.session = None + def __enter__(self): self.connect() return self @@ -102,39 +120,69 @@ class GarminClient(object): def _authenticate(self): log.info("authenticating user ...") + form_data = { "username": self.username, "password": self.password, - "embed": "false" + "embed": "false", + "_csrf": self._get_csrf_token(), } - request_params = { - "service": "https://connect.garmin.com/modern" + headers = { + 'origin': 'https://sso.garmin.com', } - headers={'origin': 'https://sso.garmin.com'} + if self._user_agent_fn: + user_agent = self._user_agent_fn() + if not user_agent: + raise ValueError("user_agent_fn didn't produce a value") + headers['User-Agent'] = user_agent + auth_response = self.session.post( - SSO_LOGIN_URL, headers=headers, params=request_params, data=form_data) + SSO_SIGNIN_URL, headers=headers, params=self._auth_params(), data=form_data) log.debug("got auth response: %s", auth_response.text) if auth_response.status_code != 200: - raise ValueError( - "authentication failure: did you enter valid credentials?") - auth_ticket_url = self._extract_auth_ticket_url( - auth_response.text) + raise ValueError("authentication failure: did you enter valid credentials?") + auth_ticket_url = self._extract_auth_ticket_url(auth_response.text) log.debug("auth ticket url: '%s'", auth_ticket_url) log.info("claiming auth ticket ...") response = self.session.get(auth_ticket_url) if response.status_code != 200: raise RuntimeError( - "auth failure: failed to claim auth ticket: %s: %d\n%s" % - (auth_ticket_url, response.status_code, response.text)) + "auth failure: failed to claim auth ticket: {}: {}\n{}".format( + auth_ticket_url, response.status_code, response.text)) - # appears like we need to touch base with the old API to initiate - # some form of legacy session. otherwise certain downloads will fail. - self.session.get('https://connect.garmin.com/legacy/session') + # appears like we need to touch base with the main page to complete the + # login ceremony. + self.session.get('https://connect.garmin.com/modern') + def _get_csrf_token(self): + """Retrieves a Cross-Site Request Forgery (CSRF) token from Garmin's login + page. The token is passed along in the login form for increased + security.""" + log.info("fetching CSRF token ...") + resp = self.session.get(SSO_LOGIN_URL, params=self._auth_params()) + if resp.status_code != 200: + raise ValueError("auth failure: could not load {}".format(SSO_LOGIN_URL)) + # extract CSRF token + csrf_token = re.search(r'1: + # duplicate of existing activity + elif len(j["failures"]) == 1 and len(j["successes"]) == 0 and response.status_code == 409: + log.info(u"duplicate activity uploaded, continuing") + activity_id = j["failures"][0]["internalId"] + + # need to poll until success/failure + elif len(j["failures"]) == 0 and len(j["successes"]) == 0 and response.status_code == 202: + retryer = Retryer( + returnval_predicate=bool, + delay_strategy=ExponentialBackoffDelayStrategy(initial_delay=timedelta(seconds=1)), + stop_strategy=MaxRetriesStopStrategy(6), # wait for up to 64 seconds (2**6) + error_strategy=None + ) + activity_id = retryer.call(self._poll_upload_completion, j["uploadUuid"]["uuid"], j["creationDate"]) + + # don't know how to handle multiple activities + elif len(j["successes"]) > 1: raise Exception(u"uploading {} resulted in multiple activities ({})".format( format, len(j["successes"]))) - activity_id = j["successes"][0]["internalId"] + # all other errors + else: + raise Exception(u"failed to upload {} for activity: {}\n{}".format( + format, response.status_code, j["failures"])) # add optional fields data = {} - if name is not None: data['activityName'] = name - if description is not None: data['description'] = name - if activity_type is not None: data['activityTypeDTO'] = {"typeKey": activity_type} - if private: data['privacy'] = {"typeKey": "private"} + if name is not None: + data['activityName'] = name + if description is not None: + data['description'] = description + if activity_type is not None: + data['activityTypeDTO'] = {"typeKey": activity_type} + if private: + data['privacy'] = {"typeKey": "private"} if data: data['activityId'] = activity_id - encoding_headers = {"Content-Type": "application/json; charset=UTF-8"} # see Tapiriik - response = self.session.put("https://connect.garmin.com/proxy/activity-service/activity/{}".format(activity_id), data=json.dumps(data), headers=encoding_headers) + encoding_headers = {"Content-Type": "application/json; charset=UTF-8"} # see Tapiriik + response = self.session.put( + "https://connect.garmin.com/proxy/activity-service/activity/{}".format(activity_id), + data=json.dumps(data), headers=encoding_headers) if response.status_code != 204: raise Exception(u"failed to set metadata for activity {}: {}\n{}".format( activity_id, response.status_code, response.text)) diff --git a/garminexport/incremental_backup.py b/garminexport/incremental_backup.py new file mode 100644 index 0000000..6c0f883 --- /dev/null +++ b/garminexport/incremental_backup.py @@ -0,0 +1,81 @@ +#! /usr/bin/env python +import getpass +import logging +import os +from datetime import timedelta +from typing import Callable, List + +import garminexport.backup +from garminexport.backup import supported_export_formats +from garminexport.garminclient import GarminClient +from garminexport.retryer import Retryer, ExponentialBackoffDelayStrategy, MaxRetriesStopStrategy + +log = logging.getLogger(__name__) + + +def incremental_backup(username: str, + password: str = None, + user_agent_fn: Callable[[],str] = None, + backup_dir: str = os.path.join(".", "activities"), + export_formats: List[str] = None, + ignore_errors: bool = False, + max_retries: int = 7): + """Performs (incremental) backups of activities for a given Garmin Connect account. + + :param username: Garmin Connect user name + :param password: Garmin Connect user password. Default: None. If not provided, would be asked interactively. + :keyword user_agent_fn: A function that, when called, produces a + `User-Agent` string to be used as `User-Agent` for the remainder of the + session. If set to None, the default user agent of the http request + library is used. + :type user_agent_fn: Callable[[], str] + :param backup_dir: Destination directory for downloaded activities. Default: ./activities/". + :param export_formats: List of desired output formats (json_summary, json_details, gpx, tcx, fit). + Default: `None` which means all supported formats will be backed up. + :param ignore_errors: Ignore errors and keep going. Default: False. + :param max_retries: The maximum number of retries to make on failed attempts to fetch an activity. + Exponential backoff will be used, meaning that the delay between successive attempts + will double with every retry, starting at one second. Default: 7. + + The activities are stored in a local directory on the user's computer. + The backups are incremental, meaning that only activities that aren't already + stored in the backup directory will be downloaded. + + """ + # if no --format was specified, all formats are to be backed up + export_formats = export_formats if export_formats else supported_export_formats + log.info("backing up formats: %s", ", ".join(export_formats)) + + if not os.path.isdir(backup_dir): + os.makedirs(backup_dir) + + if not password: + password = getpass.getpass("Enter password: ") + + # set up a retryer that will handle retries of failed activity downloads + retryer = Retryer( + delay_strategy=ExponentialBackoffDelayStrategy(initial_delay=timedelta(seconds=1)), + stop_strategy=MaxRetriesStopStrategy(max_retries)) + + with GarminClient(username, password, user_agent_fn) as client: + # get all activity ids and timestamps from Garmin account + log.info("scanning activities for %s ...", username) + activities = set(retryer.call(client.list_activities)) + log.info("account has a total of %d activities", len(activities)) + + missing_activities = garminexport.backup.need_backup(activities, backup_dir, export_formats) + backed_up = activities - missing_activities + log.info("%s contains %d backed up activities", backup_dir, len(backed_up)) + + log.info("activities that aren't backed up: %d", len(missing_activities)) + + for index, activity in enumerate(missing_activities): + id, start = activity + log.info("backing up activity %s from %s (%d out of %d) ...", + id, start, index + 1, len(missing_activities)) + try: + garminexport.backup.download(client, activity, retryer, backup_dir, export_formats) + except Exception as e: + log.error("failed with exception: %s", e) + if not ignore_errors: + raise diff --git a/garminexport/logging_config.py b/garminexport/logging_config.py new file mode 100644 index 0000000..c4a858d --- /dev/null +++ b/garminexport/logging_config.py @@ -0,0 +1,9 @@ +import logging + +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.""" diff --git a/garminexport/retryer.py b/garminexport/retryer.py index df98218..41a2c6e 100644 --- a/garminexport/retryer.py +++ b/garminexport/retryer.py @@ -1,15 +1,14 @@ import abc - -from datetime import datetime -from datetime import timedelta import logging import time +from datetime import datetime +from datetime import timedelta log = logging.getLogger(__name__) + class GaveUpError(Exception): - """Raised by a :class:`Retryer` that has exceeded its maximum number - of retries.""" + """Raised by a :class:`Retryer` that has exceeded its maximum number of retries.""" pass @@ -22,8 +21,7 @@ class DelayStrategy(object): def next_delay(self, attempts): """Returns the time to wait before the next attempt. - :param attempts: The total number of (failed) attempts performed thus - far. + :param attempts: The total number of (failed) attempts performed thus far. :type attempts: int :return: The delay before the next attempt. @@ -33,8 +31,8 @@ class DelayStrategy(object): class FixedDelayStrategy(DelayStrategy): - """A retry :class:`DelayStrategy` that produces a fixed delay between - attempts.""" + """A retry :class:`DelayStrategy` that produces a fixed delay between attempts.""" + def __init__(self, delay): """ :param delay: Attempt delay. @@ -56,7 +54,7 @@ class ExponentialBackoffDelayStrategy(DelayStrategy): def __init__(self, initial_delay): """ :param initial_delay: Initial delay. - :type delay: `timedelta` + :type initial_delay: `timedelta` """ self.initial_delay = initial_delay @@ -68,25 +66,21 @@ class ExponentialBackoffDelayStrategy(DelayStrategy): class NoDelayStrategy(FixedDelayStrategy): - """A retry :class:`DelayStrategy` that doesn't introduce any delay between - attempts.""" + """A retry :class:`DelayStrategy` that doesn't introduce any delay between attempts.""" + def __init__(self): super(NoDelayStrategy, self).__init__(timedelta(seconds=0)) - - class ErrorStrategy(object): """Used by a :class:`Retryer` to determine which errors are to be - suppressed and which errors are to be re-raised and thereby end the - (re)trying.""" + suppressed and which errors are to be re-raised and thereby end the (re)trying.""" __metaclass__ = abc.ABCMeta @abc.abstractmethod def should_suppress(self, error): """Called after an attempt that raised an exception to determine if - that error should be suppressed (continue retrying) or be re-raised - (and end the retrying). + that error should be suppressed (continue retrying) or be re-raised (and end the retrying). :param error: Error that was raised from an attempt. """ @@ -122,13 +116,14 @@ class StopStrategy(object): class NeverStopStrategy(StopStrategy): """A :class:`StopStrategy` that never gives up.""" + def should_continue(self, attempts, elapsed_time): return True class MaxRetriesStopStrategy(StopStrategy): - """A :class:`StopStrategy` that gives up after a certain number of - retries.""" + """A :class:`StopStrategy` that gives up after a certain number of retries.""" + def __init__(self, max_retries): self.max_retries = max_retries @@ -149,6 +144,7 @@ class Retryer(object): to decide if the error should be suppressed or re-raised (in which case the retrying ends with that error). """ + def __init__( self, returnval_predicate=lambda returnval: True, @@ -172,7 +168,7 @@ class Retryer(object): :param stop_strategy: determines when we are to stop retrying. :type stop_strategy: :class:`StopStrategy` :param error_strategy: determines which errors (if any) to suppress - when raised by the called function. + when raised by the called function (`None` to stop on any error). :type error_strategy: :class:`ErrorStrategy` """ self.returnval_predicate = returnval_predicate @@ -180,7 +176,6 @@ class Retryer(object): self.stop_strategy = stop_strategy self.error_strategy = error_strategy - def call(self, function, *args, **kw): """Calls the given `function`, with the given arguments, repeatedly until either (1) a satisfactory result is obtained (as indicated by @@ -208,16 +203,13 @@ class Retryer(object): return returnval log.debug('{%s}: failed: return value: %s', name, returnval) except Exception as e: - if not self.error_strategy.should_suppress(e): + if self.error_strategy is None or not self.error_strategy.should_suppress(e): raise e - log.debug('{%s}: failed: error: %s', name, str(e)) + log.debug('{%s}: failed: error: %s', name, e) elapsed_time = datetime.now() - start # should we make another attempt? if not self.stop_strategy.should_continue(attempts, elapsed_time): - raise GaveUpError( - '{%s}: gave up after %d failed attempt(s)' % - (name, attempts)) + raise GaveUpError('{{}}: gave up after {} failed attempt(s)'.format(name, attempts)) delay = self.delay_strategy.next_delay(attempts) - log.info('{%s}: waiting %d seconds for next attempt' % - (name, delay.total_seconds())) + log.info('{%s}: waiting %d seconds for next attempt', name, delay.total_seconds()) time.sleep(delay.total_seconds()) diff --git a/get_activity.py b/get_activity.py deleted file mode 100755 index c557b9c..0000000 --- a/get_activity.py +++ /dev/null @@ -1,90 +0,0 @@ -#! /usr/bin/env python -"""A program that downloads one particular activity from a given Garmin -Connect account and stores it locally on the user's computer. -""" -import argparse -from datetime import timedelta -import getpass -from garminexport.garminclient import GarminClient -import garminexport.backup -from garminexport.retryer import ( - Retryer, ExponentialBackoffDelayStrategy, MaxRetriesStopStrategy) -import json -import logging -import os -import sys -import traceback -import dateutil.parser - -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 one particular activity for a given " - "Garmin Connect account.")) - # positional args - parser.add_argument( - "username", metavar="", type=str, help="Account user name.") - parser.add_argument( - "activity", metavar="", type=int, help="Activity ID.") - parser.add_argument( - "format", metavar="", type=str, - help="Export format (one of: {}).".format( - garminexport.backup.export_formats)) - - # optional args - parser.add_argument( - "--password", type=str, help="Account password.") - parser.add_argument( - "--destination", metavar="DIR", type=str, - help=("Destination directory for downloaded activity. Default: " - "./activities/"), default=os.path.join(".", "activities")) - 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)) - if not args.format in garminexport.backup.export_formats: - raise ValueError( - "Uncrecognized export format: '{}'. Must be one of {}".format( - args.format, garminexport.backup.export_formats)) - logging.root.setLevel(LOG_LEVELS[args.log_level]) - - try: - if not os.path.isdir(args.destination): - 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 activity {} ...".format(args.activity)) - summary = client.get_activity_summary(args.activity) - # set up a retryer that will handle retries of failed activity - # downloads - retryer = Retryer( - delay_strategy=ExponentialBackoffDelayStrategy( - initial_delay=timedelta(seconds=1)), - stop_strategy=MaxRetriesStopStrategy(5)) - - starttime = dateutil.parser.parse(summary["summaryDTO"]["startTimeGMT"]) - garminexport.backup.download( - client, (args.activity, starttime), retryer, args.destination, export_formats=[args.format]) - 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/requirements.txt b/requirements.txt deleted file mode 100644 index b3728cb..0000000 --- a/requirements.txt +++ /dev/null @@ -1,7 +0,0 @@ -requests==2.21.0 -python-dateutil==2.4.1 -future==0.16.0 - -nose==1.3.7 -coverage==4.2 -mock==2.0.0 diff --git a/samples/lab.py b/samples/lab.py index 76fd088..425fe4c 100644 --- a/samples/lab.py +++ b/samples/lab.py @@ -10,10 +10,9 @@ Garmin Connect. import argparse import getpass -from garminexport.garminclient import GarminClient -import json import logging -import sys + +from garminexport.garminclient import GarminClient logging.basicConfig(level=logging.INFO, format="%(asctime)-15s [%(levelname)s] %(message)s") log = logging.getLogger(__name__) diff --git a/samples/sample.py b/samples/sample.py index 9438802..0b25b52 100755 --- a/samples/sample.py +++ b/samples/sample.py @@ -2,16 +2,16 @@ import argparse import getpass -from garminexport.garminclient import GarminClient import json import logging -import sys + +from garminexport.garminclient import GarminClient 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 @@ -26,22 +26,22 @@ if __name__ == "__main__": 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_activities() - log.info("num ids: {}".format(len(activity_ids))) + log.info("num ids: %d", len(activity_ids)) log.info(activity_ids) latest_activity, latest_activity_start = 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("activity id: %s", activity["activity"]["activityId"]) + log.info("activity name: '%s'", activity["activity"]["activityName"]) + log.info("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.error("failed with exception: %s", e) + finally: log.info("done") diff --git a/setup.py b/setup.py index f19737a..9b4089b 100644 --- a/setup.py +++ b/setup.py @@ -1,26 +1,70 @@ -#!/usr/bin/env python - """Setup information for the Garmin Connect activity exporter.""" -from setuptools import find_packages -from distutils.core import setup +from setuptools import setup, Extension +from os import path +# needed for Python 2.7 (ensures open() defaults to text mode with universal +# newlines, and accepts an argument to specify the text encoding. +from io import open + +here = path.abspath(path.dirname(__file__)) + +with open(path.join(here, 'README.md'), encoding='utf-8') as f: + long_description = f.read() + +requires = [ + 'requests>=2.0,<3', + 'python-dateutil~=2.4', +] + +test_requires = [ + 'nose~=1.3', + 'coverage~=4.2', + 'mock~=2.0', +] + +setup(name='garminexport', + version='0.4.0', + description='Garmin Connect activity exporter and backup tool', + long_description=long_description, + long_description_content_type='text/markdown', + author='Peter Gardfjäll', + author_email='peter.gardfjall.work@gmail.com', -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' + 'Intended Audience :: End Users/Desktop', + 'Intended Audience :: Developers', 'Natural Language :: English', 'License :: OSI Approved :: Apache Software License', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3.5+', - ]) + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + ], + keywords='garmin export backup', + url='https://github.com/petergardfjall/garminexport', + license='Apache License 2.0', + + project_urls={ + 'Source': 'https://github.com/petergardfjall/garminexport.git', + 'Tracker': 'https://github.com/petergardfjall/garminexport/issues', + }, + + packages=[ + 'garminexport', + 'garminexport.cli', + ], + + python_requires='>=3.5.*, <4', + install_requires=requires, + test_requires=test_requires, + entry_points={ + 'console_scripts': [ + 'garmin-backup = garminexport.cli.backup:main', + 'garmin-get-activity = garminexport.cli.get_activity:main', + 'garmin-upload-activity = garminexport.cli.upload_activity:main', + ], + }, +)