From 4934ab3fe3b5fbfd3130b7c922e7d06926c89afc Mon Sep 17 00:00:00 2001 From: Christian Wygoda Date: Tue, 23 Apr 2019 21:27:06 +0200 Subject: [PATCH 01/32] Relax dependency version to use compatible releases --- requirements.txt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/requirements.txt b/requirements.txt index b3728cb..fd73001 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ -requests==2.21.0 -python-dateutil==2.4.1 -future==0.16.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 +nose~=1.3.7 +coverage~=4.2 +mock~=2.0.0 From cf1b265b758d6bfc4c9d5635129299fbb6c78d7b Mon Sep 17 00:00:00 2001 From: Christian Wygoda Date: Thu, 25 Apr 2019 01:15:37 +0200 Subject: [PATCH 02/32] Relax dependencies on minor level --- requirements.txt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements.txt b/requirements.txt index fd73001..e216114 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ -requests~=2.21.0 -python-dateutil~=2.4.1 -future~=0.16.0 +requests~=2.21 +python-dateutil~=2.4 +future~=0.16 -nose~=1.3.7 +nose~=1.3 coverage~=4.2 -mock~=2.0.0 +mock~=2.0 From c02e23fb3e30ffd4dcbc2693c1aa00e749fadaad Mon Sep 17 00:00:00 2001 From: petergardfjall Date: Sat, 12 Oct 2019 08:24:13 +0200 Subject: [PATCH 03/32] README: add format descriptions --- README.md | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0f8d57a..617ce52 100644 --- a/README.md +++ b/README.md @@ -67,21 +67,47 @@ by default, the program downloads all formats for every activity. Use the 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 if an activity was uploaded in gpx format. In that case, Garmin won't try to synthesize a tcx file.* + [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.* + 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. From 41f8701c0691ca297674ffbfae547627031744d2 Mon Sep 17 00:00:00 2001 From: petergardfjall Date: Sat, 12 Oct 2019 08:27:24 +0200 Subject: [PATCH 04/32] README: format descriptions in smaller font size --- README.md | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 617ce52..01a42d2 100644 --- a/README.md +++ b/README.md @@ -70,43 +70,43 @@ Supported export formats: - ``gpx``: activity GPX file (XML). - [GPX](https://en.wikipedia.org/wiki/GPS_Exchange_Format) is an open + [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. + 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 if an activity was uploaded in gpx format. In that case, Garmin won't try to synthesize a tcx file.* - [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). + [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.* - 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. + 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 + 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. + 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. + 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. From 8cd27fcb19e62bf37ab7324e731d949f5ec6d1e4 Mon Sep 17 00:00:00 2001 From: Stanislav Khrapov Date: Sun, 8 Mar 2020 17:19:19 +0100 Subject: [PATCH 05/32] Refactor main script logic into an incremental_backup library function The overall intent is to make it easier for third-party clients to implement incremental backups as simple function calls to the garminexport library, rather than executing the garminbackup.py script. Co-authored-by: Stanislav Khrapov --- garminbackup.py | 121 ++++------------------------- garminexport/backup.py | 3 +- garminexport/cli.py | 49 ++++++++++++ garminexport/incremental_backup.py | 72 +++++++++++++++++ garminexport/logging_config.py | 9 +++ get_activity.py | 25 ++---- upload_activity.py | 15 +--- 7 files changed, 156 insertions(+), 138 deletions(-) create mode 100644 garminexport/cli.py create mode 100644 garminexport/incremental_backup.py create mode 100644 garminexport/logging_config.py diff --git a/garminbackup.py b/garminbackup.py index 82ef3e0..99b7c01 100755 --- a/garminbackup.py +++ b/garminbackup.py @@ -1,124 +1,29 @@ #! /usr/bin/env python -"""Performs (incremental) backups of activities for a given Garmin Connect -account. +"""This python script calls garminexport.garminbackup module with CLI parsed arguments +and 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") +from garminexport.cli import parse_args +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__) -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)) - + args = parse_args() logging.root.setLevel(LOG_LEVELS[args.log_level]) try: - if not os.path.isdir(args.backup_dir): - os.makedirs(args.backup_dir) + incremental_backup(username=args.username, + password=args.password, + backup_dir=args.backup_dir, + format=args.format, + max_retries=args.max_retries) - 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/backup.py b/garminexport/backup.py index 18b49ee..943df5e 100644 --- a/garminexport/backup.py +++ b/garminexport/backup.py @@ -2,10 +2,9 @@ """ import codecs import json -from datetime import datetime -import dateutil.parser import logging import os +from datetime import datetime log = logging.getLogger(__name__) diff --git a/garminexport/cli.py b/garminexport/cli.py new file mode 100644 index 0000000..a8cbd70 --- /dev/null +++ b/garminexport/cli.py @@ -0,0 +1,49 @@ +import argparse +import os + +from garminexport.backup import export_formats + +DEFAULT_MAX_RETRIES = 7 +"""The default maximum number of retries to make when fetching a single activity.""" + + +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( + 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) + + return parser.parse_args() diff --git a/garminexport/incremental_backup.py b/garminexport/incremental_backup.py new file mode 100644 index 0000000..25552fe --- /dev/null +++ b/garminexport/incremental_backup.py @@ -0,0 +1,72 @@ +#! /usr/bin/env python +import getpass +import logging +import os +from datetime import timedelta + +import garminexport.backup +from garminexport.backup import 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, + backup_dir: str = os.path.join(".", "activities"), + format: str = 'ALL', + 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. + :param backup_dir: Destination directory for downloaded activities. Default: ./activities/". + :param format: Desired output formats (json_summary, json_details, gpx, tcx, fit). Default: ALL. + :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 + format = format if format else export_formats + log.info("backing up formats: %s", ", ".join(format)) + + 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) 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, format) + 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 %d from %s (%d out of %d) ..." % ( + id, start, index + 1, len(missing_activities))) + try: + garminexport.backup.download(client, activity, retryer, backup_dir, format) + except Exception as e: + log.error(u"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/get_activity.py b/get_activity.py index c557b9c..48f20da 100755 --- a/get_activity.py +++ b/get_activity.py @@ -3,30 +3,21 @@ 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 +from datetime import timedelta + import dateutil.parser -logging.basicConfig( - level=logging.INFO, format="%(asctime)-15s [%(levelname)s] %(message)s") -log = logging.getLogger(__name__) +import garminexport.backup +from garminexport.garminclient import GarminClient +from garminexport.logging_config import LOG_LEVELS +from garminexport.retryer import Retryer, ExponentialBackoffDelayStrategy, MaxRetriesStopStrategy -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.""" +logging.basicConfig(level=logging.INFO, format="%(asctime)-15s [%(levelname)s] %(message)s") +log = logging.getLogger(__name__) if __name__ == "__main__": diff --git a/upload_activity.py b/upload_activity.py index 37f2da0..462b9ff 100755 --- a/upload_activity.py +++ b/upload_activity.py @@ -4,22 +4,15 @@ 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__": From 4ee0cda31457f254b75687dce4edc38e9d2bfa4a Mon Sep 17 00:00:00 2001 From: Stanislav Khrapov Date: Sun, 8 Mar 2020 20:05:56 +0100 Subject: [PATCH 06/32] cosmetic code cleanup Co-authored-by: Stanislav Khrapov --- garminbackup.py | 2 +- garminexport/backup.py | 37 ++++----- garminexport/cli.py | 9 ++- garminexport/garminclient.py | 126 ++++++++++++++--------------- garminexport/incremental_backup.py | 14 ++-- garminexport/retryer.py | 52 +++++------- get_activity.py | 42 +++++----- samples/lab.py | 5 +- samples/sample.py | 12 +-- setup.py | 3 +- upload_activity.py | 30 +++---- 11 files changed, 160 insertions(+), 172 deletions(-) diff --git a/garminbackup.py b/garminbackup.py index 99b7c01..4f049ef 100755 --- a/garminbackup.py +++ b/garminbackup.py @@ -26,4 +26,4 @@ if __name__ == "__main__": max_retries=args.max_retries) except Exception as e: - log.error(u"failed with exception: %s", str(e)) + log.error("failed with exception: {}".format(e)) diff --git a/garminexport/backup.py b/garminexport/backup.py index 943df5e..709cb28 100644 --- a/garminexport/backup.py +++ b/garminexport/backup.py @@ -8,7 +8,7 @@ from datetime import datetime log = logging.getLogger(__name__) -export_formats=["json_summary", "json_details", "gpx", "tcx", "fit"] +export_formats = ["json_summary", "json_details", "gpx", "tcx", "fit"] """The range of supported export formats for activities.""" format_suffix = { @@ -20,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. @@ -48,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): @@ -64,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)` """ @@ -86,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("{} tried but failed activities in {}".format(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. @@ -117,31 +117,27 @@ def download(client, activity, retryer, backup_dir, export_formats=None): id = activity[0] if 'json_summary' in export_formats: - log.debug("getting json summary for %s", id) + log.debug("getting json summary for {}".format(id)) activity_summary = retryer.call(client.get_activity_summary, id) 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) + log.debug("getting json details for {}".format(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) + log.debug("getting gpx for {}".format(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: @@ -149,10 +145,9 @@ def download(client, activity, retryer, backup_dir, export_formats=None): f.write(activity_gpx) if 'tcx' in export_formats: - log.debug("getting tcx for %s", id) + log.debug("getting tcx for {}".format(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: @@ -160,7 +155,7 @@ def download(client, activity, retryer, backup_dir, export_formats=None): f.write(activity_tcx) if 'fit' in export_formats: - log.debug("getting fit for %s", id) + log.debug("getting fit for {}".format(id)) activity_fit = retryer.call(client.get_activity_fit, id) dest = os.path.join( backup_dir, export_filename(activity, 'fit')) diff --git a/garminexport/cli.py b/garminexport/cli.py index a8cbd70..6478922 100644 --- a/garminexport/cli.py +++ b/garminexport/cli.py @@ -36,14 +36,15 @@ def parse_args() -> argparse.Namespace: parser.add_argument( "-f", "--format", choices=export_formats, default=None, action='append', - help="Desired output formats (" + ', '.join(export_formats) + "). Default: ALL.") + help="Desired output formats ({}). Default: ALL.".format(', '.join(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: %d") % 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)) return parser.parse_args() diff --git a/garminexport/garminclient.py b/garminexport/garminclient.py index 8fcad4b..6309841 100755 --- a/garminexport/garminclient.py +++ b/garminexport/garminclient.py @@ -6,16 +6,17 @@ 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 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 # # Note: For more detailed information about the API services @@ -45,12 +46,14 @@ 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 @@ -110,31 +113,28 @@ class GarminClient(object): request_params = { "service": "https://connect.garmin.com/modern" } - headers={'origin': 'https://sso.garmin.com'} + headers = {'origin': 'https://sso.garmin.com'} auth_response = self.session.post( SSO_LOGIN_URL, headers=headers, params=request_params, data=form_data) - log.debug("got auth response: %s", auth_response.text) + log.debug("got auth response: {}".format(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) - log.debug("auth ticket url: '%s'", auth_ticket_url) + 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: '{}'".format(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') - - - def _extract_auth_ticket_url(self, auth_response): + @staticmethod + def _extract_auth_ticket_url(auth_response): """Extracts an authentication ticket URL from the response of an authentication form submission. The auth ticket URL is typically of form: @@ -143,22 +143,19 @@ class GarminClient(object): :param auth_response: HTML response from an auth form submission. """ - match = re.search( - r'response_url\s*=\s*"(https:[^"]+)"', auth_response) + match = re.search(r'response_url\s*=\s*"(https:[^"]+)"', auth_response) if not match: raise RuntimeError( "auth failure: unable to extract auth ticket URL. did you provide a correct username/password?") auth_ticket_url = match.group(1).replace("\\", "") return auth_ticket_url - @require_session def list_activities(self): """Return all activity ids stored by the logged in user, along with their starting timestamps. - :returns: The full list of activity identifiers (along with their - starting timestamps). + :returns: The full list of activity identifiers (along with their starting timestamps). :rtype: tuples of (int, datetime) """ ids = [] @@ -178,28 +175,24 @@ class GarminClient(object): timestamps) 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. + 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 (along with their - starting timestamps). + :returns: A list of activity identifiers (along with their starting timestamps). :rtype: tuples of (int, datetime) """ - log.debug("fetching activities {} through {} ...".format( - start_index, start_index+max_limit-1)) + log.debug("fetching activities {} through {} ...".format(start_index, start_index + max_limit - 1)) response = self.session.get( "https://connect.garmin.com/modern/proxy/activitylist-service/activities/search/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)) + start_index, (start_index + max_limit - 1), response.status_code, response.text)) activities = json.loads(response.text) if not activities: # index out of bounds or empty account @@ -211,23 +204,23 @@ class GarminClient(object): timestamp_utc = dateutil.parser.parse(activity["startTimeGMT"]) # make sure UTC timezone gets set timestamp_utc = timestamp_utc.replace(tzinfo=dateutil.tz.tzutc()) - entries.append( (id, timestamp_utc) ) + entries.append((id, timestamp_utc)) 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). + """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/modern/proxy/activity-service/activity/{}".format(activity_id)) + response = self.session.get( + "https://connect.garmin.com/modern/proxy/activity-service/activity/{}".format(activity_id)) if response.status_code != 200: log.error(u"failed to fetch json summary for activity {}: {}\n{}".format( activity_id, response.status_code, response.text)) @@ -247,7 +240,8 @@ class GarminClient(object): :rtype: dict """ # mounted at xml or json depending on result encoding - response = self.session.get("https://connect.garmin.com/modern/proxy/activity-service/activity/{}/details".format(activity_id)) + response = self.session.get( + "https://connect.garmin.com/modern/proxy/activity-service/activity/{}/details".format(activity_id)) if response.status_code != 200: raise Exception(u"failed to fetch json activityDetails for {}: {}\n{}".format( activity_id, response.status_code, response.text)) @@ -266,11 +260,12 @@ class GarminClient(object): or ``None`` if the activity couldn't be exported to GPX. :rtype: str """ - response = self.session.get("https://connect.garmin.com/modern/proxy/download-service/export/gpx/activity/{}".format(activity_id)) + response = self.session.get( + "https://connect.garmin.com/modern/proxy/download-service/export/gpx/activity/{}".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)) + # response = self.session.get("https://connect.garmin.com/proxy/activity-service-1.1/gpx/activity/{}?full=true".format(activity_id)) # A 404 (Not Found) or 204 (No Content) response are both indicators # of a gpx file not being available for the activity. It may, for @@ -282,7 +277,6 @@ class GarminClient(object): activity_id, response.status_code, response.text)) return response.text - @require_session def get_activity_tcx(self, activity_id): """Return a TCX (Training Center XML) representation of a @@ -298,7 +292,8 @@ class GarminClient(object): :rtype: str """ - response = self.session.get("https://connect.garmin.com/modern/proxy/download-service/export/tcx/activity/{}".format(activity_id)) + response = self.session.get( + "https://connect.garmin.com/modern/proxy/download-service/export/tcx/activity/{}".format(activity_id)) if response.status_code == 404: return None if response.status_code != 200: @@ -306,7 +301,6 @@ class GarminClient(object): activity_id, response.status_code, response.text)) return response.text - def get_original_activity(self, activity_id): """Return the original file that was uploaded for an activity. If the activity doesn't have any file source (for example, @@ -319,28 +313,28 @@ class GarminClient(object): its contents, or :obj:`(None,None)` if no file is found. :rtype: (str, str) """ - response = self.session.get("https://connect.garmin.com/modern/proxy/download-service/files/activity/{}".format(activity_id)) + response = self.session.get( + "https://connect.garmin.com/modern/proxy/download-service/files/activity/{}".format(activity_id)) # A 404 (Not Found) response is a clear indicator of a missing .fit # file. As of lately, the endpoint appears to have started to # respond with 500 "NullPointerException" on attempts to download a # .fit file for an activity without one. if response.status_code in [404, 500]: # Manually entered activity, no file source available - return (None,None) + return None, None if response.status_code != 200: raise Exception( u"failed to get original activity file for {}: {}\n{}".format( - activity_id, response.status_code, response.text)) + activity_id, response.status_code, response.text)) # return the first entry from the zip archive where the filename is # activity_id (should be the only entry!) - zip = zipfile.ZipFile(BytesIO(response.content), mode="r") - for path in zip.namelist(): + zip_file = zipfile.ZipFile(BytesIO(response.content), mode="r") + for path in zip_file.namelist(): fn, ext = os.path.splitext(path) - if fn==str(activity_id): - return ext[1:], zip.open(path).read() - return (None,None) - + if fn == str(activity_id): + return ext[1:], zip_file.open(path).read() + return None, None def get_activity_fit(self, activity_id): """Return a FIT representation for a given activity. If the activity @@ -358,7 +352,7 @@ class GarminClient(object): # if the file extension of the original activity file isn't 'fit', # this activity was uploaded in a different format (e.g. gpx/tcx) # and cannot be exported to fit - return orig_file if fmt=='fit' else None + return orig_file if fmt == 'fit' else None @require_session def upload_activity(self, file, format=None, name=None, description=None, activity_type=None, private=None): @@ -374,14 +368,14 @@ class GarminClient(object): :rtype: int """ - if isinstance(file, basestring): + if isinstance(file, str): file = open(file, "rb") # guess file type if unspecified fn = os.path.basename(file.name) _, ext = os.path.splitext(fn) if format is None: - if ext.lower() in ('.gpx','.tcx','.fit'): + if ext.lower() in ('.gpx', '.tcx', '.fit'): format = ext.lower()[1:] else: raise Exception(u"could not guess file type for {}".format(fn)) @@ -394,15 +388,15 @@ class GarminClient(object): # check response and get activity ID try: j = response.json()["detailedImportResult"] - except (json.JSONDecodeException, KeyError): + except (json.JSONDecodeError, KeyError): raise Exception(u"failed to upload {} for activity: {}\n{}".format( format, response.status_code, response.text)) - if len(j["failures"]) or len(j["successes"])<1: + if len(j["failures"]) or len(j["successes"]) < 1: raise Exception(u"failed to upload {} for activity: {}\n{}".format( format, response.status_code, j["failures"])) - if len(j["successes"])>1: + if len(j["successes"]) > 1: raise Exception(u"uploading {} resulted in multiple activities ({})".format( format, len(j["successes"]))) @@ -410,14 +404,20 @@ class GarminClient(object): # 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'] = name + 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 index 25552fe..181cf8b 100644 --- a/garminexport/incremental_backup.py +++ b/garminexport/incremental_backup.py @@ -35,7 +35,7 @@ def incremental_backup(username: str, """ # if no --format was specified, all formats are to be backed up format = format if format else export_formats - log.info("backing up formats: %s", ", ".join(format)) + log.info("backing up formats: {}".format(", ".join(format))) if not os.path.isdir(backup_dir): os.makedirs(backup_dir) @@ -50,23 +50,23 @@ def incremental_backup(username: str, with GarminClient(username, password) as client: # get all activity ids and timestamps from Garmin account - log.info("scanning activities for %s ...", username) + log.info("scanning activities for {} ...".format(username)) activities = set(retryer.call(client.list_activities)) - log.info("account has a total of %d activities", len(activities)) + log.info("account has a total of {} activities".format(len(activities))) missing_activities = garminexport.backup.need_backup(activities, backup_dir, format) backed_up = activities - missing_activities - log.info("%s contains %d backed up activities", backup_dir, len(backed_up)) + log.info("{} contains {} backed up activities".format(backup_dir, len(backed_up))) - log.info("activities that aren't backed up: %d", len(missing_activities)) + log.info("activities that aren't backed up: {}".format(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) ..." % ( + log.info("backing up activity {} from {} ({} out of {}) ...".format( id, start, index + 1, len(missing_activities))) try: garminexport.backup.download(client, activity, retryer, backup_dir, format) except Exception as e: - log.error(u"failed with exception: %s", e) + log.error("failed with exception: {}".format(e)) if not ignore_errors: raise diff --git a/garminexport/retryer.py b/garminexport/retryer.py index df98218..b589c81 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, @@ -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 @@ -200,24 +195,21 @@ class Retryer(object): while True: try: attempts += 1 - log.info('{%s}: attempt %d ...', name, attempts) + log.info('{{}}: attempt {} ...'.format(name, attempts)) returnval = function(*args, **kw) if self.returnval_predicate(returnval): # return value satisfies predicate, we're done! - log.debug('{%s}: success: "%s"', name, returnval) + log.debug('{{}}: success: "{}"'.format(name, returnval)) return returnval - log.debug('{%s}: failed: return value: %s', name, returnval) + log.debug('{{}}: failed: return value: {}'.format(name, returnval)) except Exception as e: if not self.error_strategy.should_suppress(e): raise e - log.debug('{%s}: failed: error: %s', name, str(e)) + log.debug('{{}}: failed: error: {}'.format(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('{{}}: waiting {} seconds for next attempt'.format(name, delay.total_seconds())) time.sleep(delay.total_seconds()) diff --git a/get_activity.py b/get_activity.py index 48f20da..44b5348 100755 --- a/get_activity.py +++ b/get_activity.py @@ -6,7 +6,6 @@ import argparse import getpass import logging import os -import sys from datetime import timedelta import dateutil.parser @@ -22,8 +21,8 @@ log = logging.getLogger(__name__) if __name__ == "__main__": parser = argparse.ArgumentParser( - description=("Downloads one particular activity for a given " - "Garmin Connect account.")) + description="Downloads one particular activity for a given Garmin Connect account.") + # positional args parser.add_argument( "username", metavar="", type=str, help="Account user name.") @@ -31,29 +30,30 @@ if __name__ == "__main__": "activity", metavar="", type=int, help="Activity ID.") parser.add_argument( "format", metavar="", type=str, - help="Export format (one of: {}).".format( - garminexport.backup.export_formats)) + 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")) + 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") + 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: + + 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.export_formats: raise ValueError( - "Uncrecognized export format: '{}'. Must be one of {}".format( + "Unrecognized export format: '{}'. Must be one of {}".format( args.format, garminexport.backup.export_formats)) + logging.root.setLevel(LOG_LEVELS[args.log_level]) try: @@ -62,20 +62,18 @@ if __name__ == "__main__": 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 + # set up a retryer that will handle retries of failed activity downloads retryer = Retryer( - delay_strategy=ExponentialBackoffDelayStrategy( - initial_delay=timedelta(seconds=1)), + delay_strategy=ExponentialBackoffDelayStrategy(initial_delay=timedelta(seconds=1)), stop_strategy=MaxRetriesStopStrategy(5)) - starttime = dateutil.parser.parse(summary["summaryDTO"]["startTimeGMT"]) + start_time = dateutil.parser.parse(summary["summaryDTO"]["startTimeGMT"]) garminexport.backup.download( - client, (args.activity, starttime), retryer, args.destination, export_formats=[args.format]) + client, (args.activity, start_time), 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) + log.error("failed with exception: {}".format(e)) raise 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..7df4afc 100755 --- a/samples/sample.py +++ b/samples/sample.py @@ -2,10 +2,10 @@ 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__) @@ -36,12 +36,12 @@ if __name__ == "__main__": 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: {}".format(activity["activity"]["activityId"])) + log.info("activity name: '{}'".format(activity["activity"]["activityName"])) + log.info("activity description: '{}'".format(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) + log.error("failed with exception: {}".format(e)) finally: log.info("done") diff --git a/setup.py b/setup.py index f19737a..c98ac8d 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,8 @@ 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."), + 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", diff --git a/upload_activity.py b/upload_activity.py index 462b9ff..10ca513 100755 --- a/upload_activity.py +++ b/upload_activity.py @@ -5,7 +5,6 @@ Connect account. import argparse import getpass import logging -import sys from garminexport.garminclient import GarminClient from garminexport.logging_config import LOG_LEVELS @@ -13,11 +12,11 @@ from garminexport.logging_config import LOG_LEVELS logging.basicConfig(level=logging.INFO, format="%(asctime)-15s [%(levelname)s] %(message)s") log = logging.getLogger(__name__) - if __name__ == "__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.") @@ -38,31 +37,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)) 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 + except Exception as e: + log.error("failed with exception: {}".format(e)) + raise From c00ccd3887a4fbc1762e16b07806c6aba9b70a11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Gardfj=C3=A4ll?= Date: Tue, 7 Apr 2020 12:49:44 +0200 Subject: [PATCH 07/32] Simplify log statements. Make use of format specifiers in the logging library rather than relying on string.format(), which makes log output unnecessarily verbose. --- garminexport/backup.py | 12 ++++++------ garminexport/garminclient.py | 12 ++++++------ garminexport/incremental_backup.py | 16 ++++++++-------- garminexport/retryer.py | 10 +++++----- get_activity.py | 4 ++-- samples/sample.py | 16 ++++++++-------- upload_activity.py | 6 +++--- 7 files changed, 38 insertions(+), 38 deletions(-) diff --git a/garminexport/backup.py b/garminexport/backup.py index 709cb28..8ad01b7 100644 --- a/garminexport/backup.py +++ b/garminexport/backup.py @@ -88,7 +88,7 @@ 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("{} tried but failed activities in {}".format(len(failed_activities), _not_found)) + log.debug("%d tried but failed activities in %s", len(failed_activities), _not_found) return failed_activities @@ -117,7 +117,7 @@ def download(client, activity, retryer, backup_dir, export_formats=None): id = activity[0] if 'json_summary' in export_formats: - log.debug("getting json summary for {}".format(id)) + log.debug("getting json summary for %s", id) activity_summary = retryer.call(client.get_activity_summary, id) dest = os.path.join( @@ -126,7 +126,7 @@ def download(client, activity, retryer, backup_dir, export_formats=None): f.write(json.dumps(activity_summary, ensure_ascii=False, indent=4)) if 'json_details' in export_formats: - log.debug("getting json details for {}".format(id)) + 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')) with codecs.open(dest, encoding="utf-8", mode="w") as f: @@ -135,7 +135,7 @@ def download(client, activity, retryer, backup_dir, export_formats=None): 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 {}".format(id)) + 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')) if activity_gpx is None: @@ -145,7 +145,7 @@ def download(client, activity, retryer, backup_dir, export_formats=None): f.write(activity_gpx) if 'tcx' in export_formats: - log.debug("getting tcx for {}".format(id)) + 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')) if activity_tcx is None: @@ -155,7 +155,7 @@ def download(client, activity, retryer, backup_dir, export_formats=None): f.write(activity_tcx) if 'fit' in export_formats: - log.debug("getting fit for {}".format(id)) + log.debug("getting fit for %s", id) activity_fit = retryer.call(client.get_activity_fit, id) dest = os.path.join( backup_dir, export_filename(activity, 'fit')) diff --git a/garminexport/garminclient.py b/garminexport/garminclient.py index 6309841..9bd88f3 100755 --- a/garminexport/garminclient.py +++ b/garminexport/garminclient.py @@ -116,11 +116,11 @@ class GarminClient(object): headers = {'origin': 'https://sso.garmin.com'} auth_response = self.session.post( SSO_LOGIN_URL, headers=headers, params=request_params, data=form_data) - log.debug("got auth response: {}".format(auth_response.text)) + 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) - log.debug("auth ticket url: '{}'".format(auth_ticket_url)) + log.debug("auth ticket url: '%s'", auth_ticket_url) log.info("claiming auth ticket ...") response = self.session.get(auth_ticket_url) @@ -185,7 +185,7 @@ class GarminClient(object): :returns: A list of activity identifiers (along with their starting timestamps). :rtype: tuples of (int, datetime) """ - log.debug("fetching activities {} through {} ...".format(start_index, start_index + max_limit - 1)) + log.debug("fetching activities %d through %d ...", start_index, start_index + max_limit - 1) response = self.session.get( "https://connect.garmin.com/modern/proxy/activitylist-service/activities/search/activities", params={"start": start_index, "limit": max_limit}) @@ -205,7 +205,7 @@ class GarminClient(object): # make sure UTC timezone gets set timestamp_utc = timestamp_utc.replace(tzinfo=dateutil.tz.tzutc()) entries.append((id, timestamp_utc)) - log.debug("got {} activities.".format(len(entries))) + log.debug("got %d activities.", len(entries)) return entries @require_session @@ -222,8 +222,8 @@ class GarminClient(object): response = self.session.get( "https://connect.garmin.com/modern/proxy/activity-service/activity/{}".format(activity_id)) if response.status_code != 200: - log.error(u"failed to fetch json summary for activity {}: {}\n{}".format( - activity_id, response.status_code, response.text)) + log.error(u"failed to fetch json summary for activity %s: %d\n%s", + activity_id, response.status_code, response.text) raise Exception(u"failed to fetch json summary for activity {}: {}\n{}".format( activity_id, response.status_code, response.text)) return json.loads(response.text) diff --git a/garminexport/incremental_backup.py b/garminexport/incremental_backup.py index 181cf8b..18bad9a 100644 --- a/garminexport/incremental_backup.py +++ b/garminexport/incremental_backup.py @@ -35,7 +35,7 @@ def incremental_backup(username: str, """ # if no --format was specified, all formats are to be backed up format = format if format else export_formats - log.info("backing up formats: {}".format(", ".join(format))) + log.info("backing up formats: %s", ", ".join(format)) if not os.path.isdir(backup_dir): os.makedirs(backup_dir) @@ -50,23 +50,23 @@ def incremental_backup(username: str, with GarminClient(username, password) as client: # get all activity ids and timestamps from Garmin account - log.info("scanning activities for {} ...".format(username)) + log.info("scanning activities for %s ...", username) activities = set(retryer.call(client.list_activities)) - log.info("account has a total of {} activities".format(len(activities))) + log.info("account has a total of %d activities", len(activities)) missing_activities = garminexport.backup.need_backup(activities, backup_dir, format) backed_up = activities - missing_activities - log.info("{} contains {} backed up activities".format(backup_dir, len(backed_up))) + log.info("%s contains %d backed up activities", backup_dir, len(backed_up)) - log.info("activities that aren't backed up: {}".format(len(missing_activities))) + 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 {} from {} ({} out of {}) ...".format( - id, start, index + 1, len(missing_activities))) + 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, format) except Exception as e: - log.error("failed with exception: {}".format(e)) + log.error("failed with exception: %s", e) if not ignore_errors: raise diff --git a/garminexport/retryer.py b/garminexport/retryer.py index b589c81..73454cf 100644 --- a/garminexport/retryer.py +++ b/garminexport/retryer.py @@ -195,21 +195,21 @@ class Retryer(object): while True: try: attempts += 1 - log.info('{{}}: attempt {} ...'.format(name, attempts)) + log.info('{%s}: attempt %d ...', name, attempts) returnval = function(*args, **kw) if self.returnval_predicate(returnval): # return value satisfies predicate, we're done! - log.debug('{{}}: success: "{}"'.format(name, returnval)) + log.debug('{%s}: success: "%s"', name, returnval) return returnval - log.debug('{{}}: failed: return value: {}'.format(name, returnval)) + log.debug('{%s}: failed: return value: %s', name, returnval) except Exception as e: if not self.error_strategy.should_suppress(e): raise e - log.debug('{{}}: failed: error: {}'.format(name, 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('{{}}: gave up after {} failed attempt(s)'.format(name, attempts)) delay = self.delay_strategy.next_delay(attempts) - log.info('{{}}: waiting {} seconds for next attempt'.format(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 index 44b5348..0330ffd 100755 --- a/get_activity.py +++ b/get_activity.py @@ -64,7 +64,7 @@ if __name__ == "__main__": args.password = getpass.getpass("Enter password: ") with GarminClient(args.username, args.password) as client: - log.info("fetching activity {} ...".format(args.activity)) + 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( @@ -75,5 +75,5 @@ if __name__ == "__main__": garminexport.backup.download( client, (args.activity, start_time), retryer, args.destination, export_formats=[args.format]) except Exception as e: - log.error("failed with exception: {}".format(e)) + log.error("failed with exception: %s", e) raise diff --git a/samples/sample.py b/samples/sample.py index 7df4afc..0b25b52 100755 --- a/samples/sample.py +++ b/samples/sample.py @@ -11,7 +11,7 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)-15s [%(levelname)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("activity id: {}".format(activity["activity"]["activityId"])) - log.info("activity name: '{}'".format(activity["activity"]["activityName"])) - log.info("activity description: '{}'".format(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("failed with exception: {}".format(e)) - finally: + log.error("failed with exception: %s", e) + finally: log.info("done") diff --git a/upload_activity.py b/upload_activity.py index 10ca513..7e8ccac 100755 --- a/upload_activity.py +++ b/upload_activity.py @@ -56,15 +56,15 @@ if __name__ == "__main__": 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) except Exception as e: log.error("upload failed: {!r}".format(e)) else: - log.info("upload successful: https://connect.garmin.com/modern/activity/{}".format(id)) + log.info("upload successful: https://connect.garmin.com/modern/activity/%s", id) except Exception as e: - log.error("failed with exception: {}".format(e)) + log.error("failed with exception: %s", e) raise From f0548d2928b4024b0cb170da8439b3bfb4fc6d83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Gardfj=C3=A4ll?= Date: Tue, 7 Apr 2020 14:15:30 +0200 Subject: [PATCH 08/32] add missing ignore_errors argument in call to incremental_backup Co-authored-by: Stanislav Khrapov khrapovs@gmail.com --- garminbackup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/garminbackup.py b/garminbackup.py index 4f049ef..d584cc2 100755 --- a/garminbackup.py +++ b/garminbackup.py @@ -23,6 +23,7 @@ if __name__ == "__main__": password=args.password, backup_dir=args.backup_dir, format=args.format, + ignore_errors=args.ignore_errors, max_retries=args.max_retries) except Exception as e: From 54dad23e1c6f71d64d1cfd6b61d97ba302687836 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Gardfj=C3=A4ll?= Date: Tue, 7 Apr 2020 14:24:55 +0200 Subject: [PATCH 09/32] enable travis CI builds Co-authored-by: Stanislav Khrapov khrapovs@gmail.com --- .travis.yml | 7 +++++++ README.md | 2 ++ 2 files changed, 9 insertions(+) create mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..551cd6c --- /dev/null +++ b/.travis.yml @@ -0,0 +1,7 @@ +language: python +python: + - "3.7" +install: + - pip install -r requirements.txt +script: + - python setup.py test diff --git a/README.md b/README.md index 01a42d2..a9af816 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +[![Build Status](https://travis-ci.org/petergardfjall/garminexport.svg?branch=master)](https://travis-ci.org/petergardfjall/garminexport) + Garmin Connect activity backup tool =================================== ``garminbackup.py`` is a program that downloads activities for a From a0d6163c520e901155d2b90c1e3a0b0ba839d151 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Gardfj=C3=A4ll?= Date: Tue, 7 Apr 2020 20:38:22 +0200 Subject: [PATCH 10/32] move all executable scripts under garminexport.cli --- garminbackup.py | 30 ------------------- garminexport/cli/__init__.py | 0 garminexport/{cli.py => cli/backup.py} | 29 ++++++++++++++++++ .../cli/get_activity.py | 2 +- .../cli/upload_activity.py | 5 ++-- 5 files changed, 32 insertions(+), 34 deletions(-) delete mode 100755 garminbackup.py create mode 100644 garminexport/cli/__init__.py rename garminexport/{cli.py => cli/backup.py} (66%) rename get_activity.py => garminexport/cli/get_activity.py (99%) rename upload_activity.py => garminexport/cli/upload_activity.py (96%) diff --git a/garminbackup.py b/garminbackup.py deleted file mode 100755 index d584cc2..0000000 --- a/garminbackup.py +++ /dev/null @@ -1,30 +0,0 @@ -#! /usr/bin/env python -"""This python script calls garminexport.garminbackup module with CLI parsed arguments -and 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 logging - -from garminexport.cli import parse_args -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__) - -if __name__ == "__main__": - args = parse_args() - logging.root.setLevel(LOG_LEVELS[args.log_level]) - - try: - incremental_backup(username=args.username, - password=args.password, - backup_dir=args.backup_dir, - format=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/__init__.py b/garminexport/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/garminexport/cli.py b/garminexport/cli/backup.py similarity index 66% rename from garminexport/cli.py rename to garminexport/cli/backup.py index 6478922..33860b1 100644 --- a/garminexport/cli.py +++ b/garminexport/cli/backup.py @@ -1,7 +1,19 @@ +"""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 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.""" @@ -14,6 +26,7 @@ def parse_args() -> argparse.Namespace: 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 " @@ -48,3 +61,19 @@ def parse_args() -> argparse.Namespace: "will double with every retry, starting at one second. DEFAULT: {}").format(DEFAULT_MAX_RETRIES)) 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, + backup_dir=args.backup_dir, + format=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/get_activity.py b/garminexport/cli/get_activity.py similarity index 99% rename from get_activity.py rename to garminexport/cli/get_activity.py index 0330ffd..9446095 100755 --- a/get_activity.py +++ b/garminexport/cli/get_activity.py @@ -18,8 +18,8 @@ from garminexport.retryer import Retryer, ExponentialBackoffDelayStrategy, MaxRe logging.basicConfig(level=logging.INFO, format="%(asctime)-15s [%(levelname)s] %(message)s") log = logging.getLogger(__name__) -if __name__ == "__main__": +def main(): parser = argparse.ArgumentParser( description="Downloads one particular activity for a given Garmin Connect account.") diff --git a/upload_activity.py b/garminexport/cli/upload_activity.py similarity index 96% rename from upload_activity.py rename to garminexport/cli/upload_activity.py index 7e8ccac..1d6d69e 100755 --- a/upload_activity.py +++ b/garminexport/cli/upload_activity.py @@ -1,6 +1,5 @@ #! /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 @@ -12,8 +11,8 @@ from garminexport.logging_config import LOG_LEVELS logging.basicConfig(level=logging.INFO, format="%(asctime)-15s [%(levelname)s] %(message)s") log = logging.getLogger(__name__) -if __name__ == "__main__": +def main(): parser = argparse.ArgumentParser( description="Uploads an activity file to a Garmin Connect account.") From bcb02afbb33e1f39d264897d501d24c4b36e0ab6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Gardfj=C3=A4ll?= Date: Tue, 7 Apr 2020 20:40:00 +0200 Subject: [PATCH 11/32] update gitignore --- .gitignore | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 5dda794..0888411 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,14 @@ *~ *.pyc -.ropeproject \ No newline at end of file +.ropeproject +.coverage + +build/ +dist/ +*.egg-info/ +*.egg +*.py[cod] +__pycache__/ +*.so + +.venv/ From d3f8819dc7cc0e145813cea21fc1020d381b3822 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Gardfj=C3=A4ll?= Date: Tue, 7 Apr 2020 20:41:07 +0200 Subject: [PATCH 12/32] update setup.py in preparation for PyPi release --- setup.py | 82 +++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 64 insertions(+), 18 deletions(-) diff --git a/setup.py b/setup.py index c98ac8d..7351e88 100644 --- a/setup.py +++ b/setup.py @@ -1,27 +1,73 @@ -#!/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, find_packages +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.21', + 'python-dateutil~=2.4', + 'future~=0.16', +] + +test_requires = [ + 'nose~=1.3', + 'coverage~=4.2', + 'mock~=2.0', +] + +setup(name='garminexport', + version='0.1.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', '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='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <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', + ], + }, +) From e7fc7e970af2ddac6f0e553fd344ac950e980328 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Gardfj=C3=A4ll?= Date: Wed, 8 Apr 2020 19:45:26 +0200 Subject: [PATCH 13/32] setup.py: remove unused dependency on future and make requests dependency looser --- setup.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 7351e88..0d868fb 100644 --- a/setup.py +++ b/setup.py @@ -12,9 +12,8 @@ with open(path.join(here, 'README.md'), encoding='utf-8') as f: long_description = f.read() requires = [ - 'requests~=2.21', + 'requests>=2.0,<3', 'python-dateutil~=2.4', - 'future~=0.16', ] test_requires = [ From 94872ae54356a4d6dd4d394b9cb57eff1a6b727b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Gardfj=C3=A4ll?= Date: Wed, 8 Apr 2020 20:11:13 +0200 Subject: [PATCH 14/32] switch from requirements.txt to pipenv/Pipfile --- Makefile | 10 +-- Pipfile | 14 ++++ Pipfile.lock | 206 +++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 7 -- 4 files changed, 222 insertions(+), 15 deletions(-) create mode 100644 Pipfile create mode 100644 Pipfile.lock delete mode 100644 requirements.txt 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..3d02fdd --- /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..6731e7f --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,206 @@ +{ + "_meta": { + "hash": { + "sha256": "59e49afac1bfe0c2329a345793c399f46a2c57f14bf3abb6efa419c736c4009c" + }, + "pipfile-spec": 6, + "requires": {}, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "certifi": { + "hashes": [ + "sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304", + "sha256:51fcb31174be6e6664c5f69e3e1691a2d72a1a12e90f872cbdb1567eb47b6519" + ], + "version": "==2020.4.5.1" + }, + "chardet": { + "hashes": [ + "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", + "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" + ], + "version": "==3.0.4" + }, + "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" + ], + "version": "==4.5.4" + }, + "future": { + "hashes": [ + "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d" + ], + "version": "==0.18.2" + }, + "garminexport": { + "editable": true, + "path": "." + }, + "idna": { + "hashes": [ + "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb", + "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa" + ], + "version": "==2.9" + }, + "mock": { + "hashes": [ + "sha256:5ce3c71c5545b472da17b72268978914d0252980348636840bd34a00b5cc96c1", + "sha256:b158b6df76edd239b8208d481dc46b6afd45a846b7812ff0ce58971cf5bc8bba" + ], + "version": "==2.0.0" + }, + "nose": { + "hashes": [ + "sha256:9ff7c6cc443f8c51994b34a667bbcf45afd6d945be7477b52e97516fd17c53ac", + "sha256:dadcddc0aefbf99eea214e0f1232b94f2fa9bd98fa8353711dacb112bfcbbb2a", + "sha256:f1bffef9cbc82628f6e7d7b40d7e255aefaa1adb6a1b1d26c69a8b79e6208a98" + ], + "version": "==1.3.7" + }, + "pbr": { + "hashes": [ + "sha256:07f558fece33b05caf857474a366dfcc00562bca13dd8b47b2b3e22d9f9bf55c", + "sha256:579170e23f8e0c2f24b0de612f71f648eccb79fb1322c814ae6b3c07b5ba23e8" + ], + "version": "==5.4.5" + }, + "python-dateutil": { + "hashes": [ + "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", + "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a" + ], + "index": "pypi", + "version": "==2.8.1" + }, + "requests": { + "hashes": [ + "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee", + "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6" + ], + "index": "pypi", + "version": "==2.23.0" + }, + "six": { + "hashes": [ + "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a", + "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c" + ], + "version": "==1.14.0" + }, + "urllib3": { + "hashes": [ + "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc", + "sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc" + ], + "version": "==1.25.8" + } + }, + "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" + ], + "version": "==4.5.4" + }, + "mock": { + "hashes": [ + "sha256:5ce3c71c5545b472da17b72268978914d0252980348636840bd34a00b5cc96c1", + "sha256:b158b6df76edd239b8208d481dc46b6afd45a846b7812ff0ce58971cf5bc8bba" + ], + "version": "==2.0.0" + }, + "nose": { + "hashes": [ + "sha256:9ff7c6cc443f8c51994b34a667bbcf45afd6d945be7477b52e97516fd17c53ac", + "sha256:dadcddc0aefbf99eea214e0f1232b94f2fa9bd98fa8353711dacb112bfcbbb2a", + "sha256:f1bffef9cbc82628f6e7d7b40d7e255aefaa1adb6a1b1d26c69a8b79e6208a98" + ], + "version": "==1.3.7" + }, + "pbr": { + "hashes": [ + "sha256:07f558fece33b05caf857474a366dfcc00562bca13dd8b47b2b3e22d9f9bf55c", + "sha256:579170e23f8e0c2f24b0de612f71f648eccb79fb1322c814ae6b3c07b5ba23e8" + ], + "version": "==5.4.5" + }, + "six": { + "hashes": [ + "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a", + "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c" + ], + "version": "==1.14.0" + } + } +} diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index e216114..0000000 --- a/requirements.txt +++ /dev/null @@ -1,7 +0,0 @@ -requests~=2.21 -python-dateutil~=2.4 -future~=0.16 - -nose~=1.3 -coverage~=4.2 -mock~=2.0 From f2225bb3b3b9fa81ac5cea2b49852f0e90576eae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Gardfj=C3=A4ll?= Date: Tue, 7 Apr 2020 20:41:45 +0200 Subject: [PATCH 15/32] README: update with new script names and pipenv use --- README.md | 125 ++++++++++++++++++++++++------------------------------ 1 file changed, 56 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index a9af816..d7a5b80 100644 --- a/README.md +++ b/README.md @@ -1,84 +1,61 @@ [![Build Status](https://travis-ci.org/petergardfjall/garminexport.svg?branch=master)](https://travis-ci.org/petergardfjall/garminexport) -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 +# 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: - - ``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.* @@ -87,8 +64,8 @@ Supported export formats: 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 + - `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.* The [FIT](https://www.thisisant.com/resources/fit/) format is the @@ -96,37 +73,47 @@ Supported export formats: 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). + - `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). + - `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. +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 From 3df00a89c9bbee62c9a13d69dced8cfaf0a890c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Gardfj=C3=A4ll?= Date: Wed, 8 Apr 2020 20:21:59 +0200 Subject: [PATCH 16/32] call export_formats supported_export_formats Co-authored-by: Stanislav Khrapov khrapovs@gmail.com --- garminexport/backup.py | 2 +- garminexport/cli/backup.py | 8 ++++---- garminexport/cli/get_activity.py | 6 +++--- garminexport/incremental_backup.py | 14 +++++++------- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/garminexport/backup.py b/garminexport/backup.py index 8ad01b7..86348a2 100644 --- a/garminexport/backup.py +++ b/garminexport/backup.py @@ -8,7 +8,7 @@ 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 = { diff --git a/garminexport/cli/backup.py b/garminexport/cli/backup.py index 33860b1..72fdeee 100644 --- a/garminexport/cli/backup.py +++ b/garminexport/cli/backup.py @@ -8,7 +8,7 @@ import argparse import logging import os -from garminexport.backup import export_formats +from garminexport.backup import supported_export_formats from garminexport.incremental_backup import incremental_backup from garminexport.logging_config import LOG_LEVELS @@ -47,9 +47,9 @@ def parse_args() -> argparse.Namespace: help="Desired log output level (DEBUG, INFO, WARNING, ERROR). Default: INFO.", default="INFO") parser.add_argument( - "-f", "--format", choices=export_formats, + "-f", "--format", choices=supported_export_formats, default=None, action='append', - help="Desired output formats ({}). Default: ALL.".format(', '.join(export_formats))) + 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") @@ -71,7 +71,7 @@ def main(): incremental_backup(username=args.username, password=args.password, backup_dir=args.backup_dir, - format=args.format, + export_formats=args.format, ignore_errors=args.ignore_errors, max_retries=args.max_retries) diff --git a/garminexport/cli/get_activity.py b/garminexport/cli/get_activity.py index 9446095..c7ceff9 100755 --- a/garminexport/cli/get_activity.py +++ b/garminexport/cli/get_activity.py @@ -30,7 +30,7 @@ def main(): "activity", metavar="", type=int, help="Activity ID.") parser.add_argument( "format", metavar="", type=str, - help="Export format (one of: {}).".format(garminexport.backup.export_formats)) + help="Export format (one of: {}).".format(garminexport.backup.supported_export_formats)) # optional args parser.add_argument( @@ -49,10 +49,10 @@ def main(): 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.export_formats: + if args.format not in garminexport.backup.supported_export_formats: raise ValueError( "Unrecognized export format: '{}'. Must be one of {}".format( - args.format, garminexport.backup.export_formats)) + args.format, garminexport.backup.supported_export_formats)) logging.root.setLevel(LOG_LEVELS[args.log_level]) diff --git a/garminexport/incremental_backup.py b/garminexport/incremental_backup.py index 18bad9a..8119524 100644 --- a/garminexport/incremental_backup.py +++ b/garminexport/incremental_backup.py @@ -5,7 +5,7 @@ import os from datetime import timedelta import garminexport.backup -from garminexport.backup import export_formats +from garminexport.backup import supported_export_formats from garminexport.garminclient import GarminClient from garminexport.retryer import Retryer, ExponentialBackoffDelayStrategy, MaxRetriesStopStrategy @@ -15,7 +15,7 @@ log = logging.getLogger(__name__) def incremental_backup(username: str, password: str = None, backup_dir: str = os.path.join(".", "activities"), - format: str = 'ALL', + export_formats: str = 'ALL', ignore_errors: bool = False, max_retries: int = 7): """Performs (incremental) backups of activities for a given Garmin Connect account. @@ -23,7 +23,7 @@ def incremental_backup(username: str, :param username: Garmin Connect user name :param password: Garmin Connect user password. Default: None. If not provided, would be asked interactively. :param backup_dir: Destination directory for downloaded activities. Default: ./activities/". - :param format: Desired output formats (json_summary, json_details, gpx, tcx, fit). Default: ALL. + :param export_formats: Desired output formats (json_summary, json_details, gpx, tcx, fit). Default: ALL. :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 @@ -34,8 +34,8 @@ def incremental_backup(username: str, stored in the backup directory will be downloaded. """ # if no --format was specified, all formats are to be backed up - format = format if format else export_formats - log.info("backing up formats: %s", ", ".join(format)) + 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) @@ -54,7 +54,7 @@ def incremental_backup(username: str, 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, format) + 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)) @@ -65,7 +65,7 @@ def incremental_backup(username: str, 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, format) + 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: From 0c95e4f50fe0cbbaf53e4cda7598be53520ecfeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Gardfj=C3=A4ll?= Date: Wed, 8 Apr 2020 20:29:29 +0200 Subject: [PATCH 17/32] drop support for (now sunset) Python 2 --- setup.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 0d868fb..031af67 100644 --- a/setup.py +++ b/setup.py @@ -37,8 +37,6 @@ setup(name='garminexport', 'Intended Audience :: Developers', 'Natural Language :: English', 'License :: OSI Approved :: Apache Software License', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', @@ -59,7 +57,7 @@ setup(name='garminexport', 'garminexport.cli', ], - python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4', + python_requires='>=3.5.*, <4', install_requires=requires, test_requires=test_requires, entry_points={ From 92024f7400e467ff783ea12031783ab0748b8bf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Gardfj=C3=A4ll?= Date: Wed, 8 Apr 2020 20:31:26 +0200 Subject: [PATCH 18/32] travis: use pipenv --- .travis.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 551cd6c..af22b8e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,6 +2,7 @@ language: python python: - "3.7" install: - - pip install -r requirements.txt + - pip install pipenv + - pipenv install script: - python setup.py test From 73c1559ba5a4482e80129144b66fc0f4b868dcd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Gardfj=C3=A4ll?= Date: Wed, 8 Apr 2020 20:41:36 +0200 Subject: [PATCH 19/32] setup.py: minor fix --- setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 031af67..b8bed0d 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ """Setup information for the Garmin Connect activity exporter.""" -from setuptools import setup, find_packages +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. @@ -24,7 +24,7 @@ test_requires = [ setup(name='garminexport', version='0.1.0', - description=('Garmin Connect activity exporter and backup tool'), + description='Garmin Connect activity exporter and backup tool', long_description=long_description, long_description_content_type='text/markdown', author='Peter Gardfjäll', From e0474057dd68ff10f01e895acac24c3ef045e269 Mon Sep 17 00:00:00 2001 From: Stanislav Khrapov Date: Fri, 29 Jun 2018 21:16:38 +0200 Subject: [PATCH 20/32] Fix error in documentation about `export_formats` argument type. --- garminexport/incremental_backup.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/garminexport/incremental_backup.py b/garminexport/incremental_backup.py index 8119524..7c6b6b3 100644 --- a/garminexport/incremental_backup.py +++ b/garminexport/incremental_backup.py @@ -3,6 +3,7 @@ import getpass import logging import os from datetime import timedelta +from typing import List import garminexport.backup from garminexport.backup import supported_export_formats @@ -15,7 +16,7 @@ log = logging.getLogger(__name__) def incremental_backup(username: str, password: str = None, backup_dir: str = os.path.join(".", "activities"), - export_formats: str = 'ALL', + export_formats: List[str] = None, ignore_errors: bool = False, max_retries: int = 7): """Performs (incremental) backups of activities for a given Garmin Connect account. @@ -23,7 +24,8 @@ def incremental_backup(username: str, :param username: Garmin Connect user name :param password: Garmin Connect user password. Default: None. If not provided, would be asked interactively. :param backup_dir: Destination directory for downloaded activities. Default: ./activities/". - :param export_formats: Desired output formats (json_summary, json_details, gpx, tcx, fit). Default: ALL. + :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 From 69dae3d94da725acab23c4cea2d41e0abdfe489e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Gardfj=C3=A4ll?= Date: Sat, 11 Apr 2020 15:13:23 +0200 Subject: [PATCH 21/32] PyPi badge --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index d7a5b80..ec33e7a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ [![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) # About `garminexport` is both a library and a utility script for downloading/backing up From 88eac30e15e77c891f1ef29f44f029087e4ae185 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Gardfj=C3=A4ll?= Date: Sat, 11 Apr 2020 15:14:41 +0200 Subject: [PATCH 22/32] python version badge --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index ec33e7a..e20f2a3 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ [![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) # About `garminexport` is both a library and a utility script for downloading/backing up From 3684ca0fb82e66dc12d161902747d58b1b746e51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Gardfj=C3=A4ll?= Date: Sat, 11 Apr 2020 15:15:59 +0200 Subject: [PATCH 23/32] license badge --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index e20f2a3..630d5b9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ [![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 From 577f5ec3c1d73fd78b90cd5264c1eb788fa2644a Mon Sep 17 00:00:00 2001 From: Carey Metcalfe Date: Wed, 30 Sep 2020 20:06:32 -0400 Subject: [PATCH 24/32] Fix setting the activity name as the description --- garminexport/garminclient.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/garminexport/garminclient.py b/garminexport/garminclient.py index 9bd88f3..9ff6b6e 100755 --- a/garminexport/garminclient.py +++ b/garminexport/garminclient.py @@ -407,7 +407,7 @@ class GarminClient(object): if name is not None: data['activityName'] = name if description is not None: - data['description'] = name + data['description'] = description if activity_type is not None: data['activityTypeDTO'] = {"typeKey": activity_type} if private: From ec0b8e651df57133743d3cf0d3a426e76af964ea Mon Sep 17 00:00:00 2001 From: Dan Lenski Date: Tue, 6 Oct 2020 21:01:57 -0700 Subject: [PATCH 25/32] gracefully handle duplicate and slow activity uploads upload_activity: handle poll-and-wait for uploads that require long processing time --- garminexport/garminclient.py | 65 ++++++++++++++++++++++++++++++++---- garminexport/retryer.py | 4 +-- 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/garminexport/garminclient.py b/garminexport/garminclient.py index 9ff6b6e..0c354f7 100755 --- a/garminexport/garminclient.py +++ b/garminexport/garminclient.py @@ -10,6 +10,7 @@ import os.path import re import sys import zipfile +from datetime import timedelta, datetime from builtins import range from functools import wraps from io import BytesIO @@ -18,6 +19,8 @@ import dateutil import dateutil.parser import requests +from garminexport.retryer import Retryer, ExponentialBackoffDelayStrategy, MaxRetriesStopStrategy + # # Note: For more detailed information about the API services # used by this module, log in to your Garmin Connect account @@ -354,16 +357,47 @@ class GarminClient(object): # and cannot be exported to fit return orig_file if fmt == 'fit' else None + @require_session + def _poll_upload_completion(self, uuid, creation_date): + """Poll for completion of an upload. If Garmin connect returns + HTTP status 202 ("Accepted") after initial upload, then we must poll + until the upload has either succeeded or failed. Raises an + :class:`Exception` if the upload has failed. + + :param uuid: uploadUuid returned on initial upload. + :type uuid: str + :param creation_date: creationDate returned from initial upload (e.g. + "2020-01-01 12:34:56.789 GMT") + :type creation_date: str + :returns: Garmin's internalId for the newly-created activity, or + :obj:`None` if upload is still processing. + :rtype: int + """ + response = self.session.get("https://connect.garmin.com/modern/proxy/activity-service/activity/status/{}/{}?_={}".format( + creation_date[:10], uuid.replace("-",""), int(datetime.now().timestamp()*1000)), headers={"nk": "NT"}) + if response.status_code == 201 and response.headers["location"]: + # location should be https://connectapi.garmin.com/activity-service/activity/ACTIVITY_ID + return int(response.headers["location"].split("/")[-1]) + elif response.status_code == 202: + return None # still processing + else: + response.raise_for_status() + @require_session def upload_activity(self, file, format=None, name=None, description=None, activity_type=None, private=None): """Upload a GPX, TCX, or FIT file for an activity. :param file: Path or open file - :param format: File format (gpx, tcx, or fit); guessed from filename if None + :param format: File format (gpx, tcx, or fit); guessed from filename if :obj:`None` + :type format: str :param name: Optional name for the activity on Garmin Connect + :type name: str :param description: Optional description for the activity on Garmin Connect + :type description: str :param activity_type: Optional activityType key (lowercase: e.g. running, cycling) + :type activityType: str :param private: If true, then activity will be set as private. + :type private: bool :returns: ID of the newly-uploaded activity :rtype: int """ @@ -392,15 +426,34 @@ class GarminClient(object): raise Exception(u"failed to upload {} for activity: {}\n{}".format( format, response.status_code, response.text)) - if len(j["failures"]) or len(j["successes"]) < 1: - raise Exception(u"failed to upload {} for activity: {}\n{}".format( - format, response.status_code, j["failures"])) + # single activity, immediate success + if len(j["successes"]) == 1 and len(j["failures"]) == 0: + activity_id = j["successes"][0]["internalId"] - if len(j["successes"]) > 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 = {} diff --git a/garminexport/retryer.py b/garminexport/retryer.py index 73454cf..41a2c6e 100644 --- a/garminexport/retryer.py +++ b/garminexport/retryer.py @@ -168,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 @@ -203,7 +203,7 @@ 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, e) elapsed_time = datetime.now() - start From 9072333a3c6854a9b82a916b6d64d5050a76319e Mon Sep 17 00:00:00 2001 From: fachleitner Date: Sat, 7 Nov 2020 07:58:22 +0100 Subject: [PATCH 26/32] relax file name matching for downloaded fit files Seems like Garmin has changed the naming scheme of `.fit` files within the zip download when getting the original activity. The scheme used to be `{activity_id}.fit.` but has been changed to `{activity_id}_ACTIVITY.fit`. This commit relaxes the name comparison. --- garminexport/garminclient.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/garminexport/garminclient.py b/garminexport/garminclient.py index 0c354f7..f8fbda6 100755 --- a/garminexport/garminclient.py +++ b/garminexport/garminclient.py @@ -335,7 +335,7 @@ class GarminClient(object): zip_file = zipfile.ZipFile(BytesIO(response.content), mode="r") for path in zip_file.namelist(): fn, ext = os.path.splitext(path) - if fn == str(activity_id): + if fn.startswith(str(activity_id)): return ext[1:], zip_file.open(path).read() return None, None From 3886c314c9f84dfee683a2039777ba784ce33691 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Gardfj=C3=A4ll?= Date: Sat, 7 Nov 2020 08:01:50 +0100 Subject: [PATCH 27/32] bump version: v0.1.0 -> v0.2.0 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index b8bed0d..73e5c6c 100644 --- a/setup.py +++ b/setup.py @@ -23,7 +23,7 @@ test_requires = [ ] setup(name='garminexport', - version='0.1.0', + version='0.2.0', description='Garmin Connect activity exporter and backup tool', long_description=long_description, long_description_content_type='text/markdown', From b62e2ac1e53b8af8f69be64dd7153301f119f607 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Gardfj=C3=A4ll?= Date: Thu, 25 Feb 2021 11:19:51 +0100 Subject: [PATCH 28/32] include CSRF-token in auth and drop 'modern' prefix in paths (#72) This fix is needed to address internal changes in the Garmin API, where service endpoints that previously worked started returning 402 responses. Appears like dropping the `modern` prefix from the endpoint paths fixes the issue. Also, the authentication sequence has been updated to include a CSRF (Cross-Site Request Forgery) token to closer mimic the behavior of the login sequence on the official web page. Co-authored-by: kfollesdal --- Pipfile | 2 +- Pipfile.lock | 122 ++++++++++------------------------- garminexport/garminclient.py | 64 ++++++++++++------ 3 files changed, 80 insertions(+), 108 deletions(-) diff --git a/Pipfile b/Pipfile index 3d02fdd..bf2d3c8 100644 --- a/Pipfile +++ b/Pipfile @@ -4,7 +4,7 @@ url = "https://pypi.org/simple" verify_ssl = true [packages] -"garminexport" = {path = ".", editable = true} +garminexport = {path = ".",editable = true} requests = ">=2.0,<3" python-dateutil = ">=2.0,<3" diff --git a/Pipfile.lock b/Pipfile.lock index 6731e7f..540c4b6 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -16,60 +16,18 @@ "default": { "certifi": { "hashes": [ - "sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304", - "sha256:51fcb31174be6e6664c5f69e3e1691a2d72a1a12e90f872cbdb1567eb47b6519" + "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c", + "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830" ], - "version": "==2020.4.5.1" + "version": "==2020.12.5" }, "chardet": { "hashes": [ - "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", - "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" + "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa", + "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5" ], - "version": "==3.0.4" - }, - "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" - ], - "version": "==4.5.4" - }, - "future": { - "hashes": [ - "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d" - ], - "version": "==0.18.2" + "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, @@ -77,32 +35,11 @@ }, "idna": { "hashes": [ - "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb", - "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa" + "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", + "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" ], - "version": "==2.9" - }, - "mock": { - "hashes": [ - "sha256:5ce3c71c5545b472da17b72268978914d0252980348636840bd34a00b5cc96c1", - "sha256:b158b6df76edd239b8208d481dc46b6afd45a846b7812ff0ce58971cf5bc8bba" - ], - "version": "==2.0.0" - }, - "nose": { - "hashes": [ - "sha256:9ff7c6cc443f8c51994b34a667bbcf45afd6d945be7477b52e97516fd17c53ac", - "sha256:dadcddc0aefbf99eea214e0f1232b94f2fa9bd98fa8353711dacb112bfcbbb2a", - "sha256:f1bffef9cbc82628f6e7d7b40d7e255aefaa1adb6a1b1d26c69a8b79e6208a98" - ], - "version": "==1.3.7" - }, - "pbr": { - "hashes": [ - "sha256:07f558fece33b05caf857474a366dfcc00562bca13dd8b47b2b3e22d9f9bf55c", - "sha256:579170e23f8e0c2f24b0de612f71f648eccb79fb1322c814ae6b3c07b5ba23e8" - ], - "version": "==5.4.5" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.10" }, "python-dateutil": { "hashes": [ @@ -114,25 +51,27 @@ }, "requests": { "hashes": [ - "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee", - "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6" + "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804", + "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e" ], "index": "pypi", - "version": "==2.23.0" + "version": "==2.25.1" }, "six": { "hashes": [ - "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a", - "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c" + "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", + "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], - "version": "==1.14.0" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.15.0" }, "urllib3": { "hashes": [ - "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc", - "sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc" + "sha256:1b465e494e3e0d8939b50680403e3aedaa2bc434b7d5af64dfd3c958d7f5ae80", + "sha256:de3eedaad74a2683334e282005cd8d7f22f4d55fa690a2a1020a416cb0a47e73" ], - "version": "==1.25.8" + "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.3" } }, "develop": { @@ -171,6 +110,7 @@ "sha256:fa964bae817babece5aa2e8c1af841bebb6d0b9add8e637548809d040443fee0", "sha256:ff37757e068ae606659c28c3bd0d923f9d29a85de79bf25b2b34b148473b5025" ], + "index": "pypi", "version": "==4.5.4" }, "mock": { @@ -178,6 +118,7 @@ "sha256:5ce3c71c5545b472da17b72268978914d0252980348636840bd34a00b5cc96c1", "sha256:b158b6df76edd239b8208d481dc46b6afd45a846b7812ff0ce58971cf5bc8bba" ], + "index": "pypi", "version": "==2.0.0" }, "nose": { @@ -186,21 +127,24 @@ "sha256:dadcddc0aefbf99eea214e0f1232b94f2fa9bd98fa8353711dacb112bfcbbb2a", "sha256:f1bffef9cbc82628f6e7d7b40d7e255aefaa1adb6a1b1d26c69a8b79e6208a98" ], + "index": "pypi", "version": "==1.3.7" }, "pbr": { "hashes": [ - "sha256:07f558fece33b05caf857474a366dfcc00562bca13dd8b47b2b3e22d9f9bf55c", - "sha256:579170e23f8e0c2f24b0de612f71f648eccb79fb1322c814ae6b3c07b5ba23e8" + "sha256:5fad80b613c402d5b7df7bd84812548b2a61e9977387a80a5fc5c396492b13c9", + "sha256:b236cde0ac9a6aedd5e3c34517b423cd4fd97ef723849da6b0d2231142d89c00" ], - "version": "==5.4.5" + "markers": "python_version >= '2.6'", + "version": "==5.5.1" }, "six": { "hashes": [ - "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a", - "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c" + "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", + "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], - "version": "==1.14.0" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.15.0" } } } diff --git a/garminexport/garminclient.py b/garminexport/garminclient.py index f8fbda6..2ac4674 100755 --- a/garminexport/garminclient.py +++ b/garminexport/garminclient.py @@ -41,8 +41,11 @@ 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): @@ -108,17 +111,16 @@ class GarminClient(object): def _authenticate(self): log.info("authenticating user ...") + form_data = { "username": self.username, "password": self.password, - "embed": "false" - } - request_params = { - "service": "https://connect.garmin.com/modern" + "embed": "false", + "_csrf": self._get_csrf_token(), } headers = {'origin': 'https://sso.garmin.com'} 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?") @@ -132,9 +134,35 @@ class GarminClient(object): "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' Date: Thu, 25 Feb 2021 11:21:54 +0100 Subject: [PATCH 29/32] bump version: 0.2.0 -> 0.3.0 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 73e5c6c..7b7a412 100644 --- a/setup.py +++ b/setup.py @@ -23,7 +23,7 @@ test_requires = [ ] setup(name='garminexport', - version='0.2.0', + version='0.3.0', description='Garmin Connect activity exporter and backup tool', long_description=long_description, long_description_content_type='text/markdown', From cbe9e74704907c60ce720791ac2032bd8f4be150 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Gardfj=C3=A4ll?= Date: Sun, 9 May 2021 07:28:03 +0200 Subject: [PATCH 30/32] bump dependency versions This bumps urllib3 from 1.26.3 to 1.26.4 since 1.26.3 was flagged by dependabot. --- Pipfile.lock | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/Pipfile.lock b/Pipfile.lock index 540c4b6..f8bb937 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -59,19 +59,19 @@ }, "six": { "hashes": [ - "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", - "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" + "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", + "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.15.0" + "version": "==1.16.0" }, "urllib3": { "hashes": [ - "sha256:1b465e494e3e0d8939b50680403e3aedaa2bc434b7d5af64dfd3c958d7f5ae80", - "sha256:de3eedaad74a2683334e282005cd8d7f22f4d55fa690a2a1020a416cb0a47e73" + "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.3" + "version": "==1.26.4" } }, "develop": { @@ -132,19 +132,19 @@ }, "pbr": { "hashes": [ - "sha256:5fad80b613c402d5b7df7bd84812548b2a61e9977387a80a5fc5c396492b13c9", - "sha256:b236cde0ac9a6aedd5e3c34517b423cd4fd97ef723849da6b0d2231142d89c00" + "sha256:42df03e7797b796625b1029c0400279c7c34fd7df24a7d7818a1abb5b38710dd", + "sha256:c68c661ac5cc81058ac94247278eeda6d2e6aecb3e227b0387c30d277e7ef8d4" ], "markers": "python_version >= '2.6'", - "version": "==5.5.1" + "version": "==5.6.0" }, "six": { "hashes": [ - "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", - "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" + "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", + "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.15.0" + "version": "==1.16.0" } } } From 33b83548675823f8f872a491363dffc9ba091992 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Gardfj=C3=A4ll?= Date: Sun, 9 May 2021 08:09:38 +0200 Subject: [PATCH 31/32] support customizing the 'User-Agent' value --- garminexport/cli/backup.py | 8 ++++++++ garminexport/garminclient.py | 21 +++++++++++++++++++-- garminexport/incremental_backup.py | 11 +++++++++-- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/garminexport/cli/backup.py b/garminexport/cli/backup.py index 72fdeee..66a563e 100644 --- a/garminexport/cli/backup.py +++ b/garminexport/cli/backup.py @@ -18,6 +18,10 @@ 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. @@ -59,6 +63,9 @@ def parse_args() -> argparse.Namespace: 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() @@ -70,6 +77,7 @@ def main(): 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, diff --git a/garminexport/garminclient.py b/garminexport/garminclient.py index 2ac4674..e221009 100755 --- a/garminexport/garminclient.py +++ b/garminexport/garminclient.py @@ -81,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 @@ -118,7 +127,15 @@ class GarminClient(object): "embed": "false", "_csrf": self._get_csrf_token(), } - 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_SIGNIN_URL, headers=headers, params=self._auth_params(), data=form_data) log.debug("got auth response: %s", auth_response.text) diff --git a/garminexport/incremental_backup.py b/garminexport/incremental_backup.py index 7c6b6b3..6c0f883 100644 --- a/garminexport/incremental_backup.py +++ b/garminexport/incremental_backup.py @@ -3,7 +3,7 @@ import getpass import logging import os from datetime import timedelta -from typing import List +from typing import Callable, List import garminexport.backup from garminexport.backup import supported_export_formats @@ -15,6 +15,7 @@ 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, @@ -23,6 +24,11 @@ def incremental_backup(username: str, :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. @@ -34,6 +40,7 @@ def incremental_backup(username: str, 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 @@ -50,7 +57,7 @@ def incremental_backup(username: str, delay_strategy=ExponentialBackoffDelayStrategy(initial_delay=timedelta(seconds=1)), stop_strategy=MaxRetriesStopStrategy(max_retries)) - with GarminClient(username, password) as client: + 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)) From c2a7bf92a0e7b0c1d02e238b50c701aabad9162e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Gardfj=C3=A4ll?= Date: Sun, 9 May 2021 08:18:47 +0200 Subject: [PATCH 32/32] bump version: 0.4.0 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 7b7a412..9b4089b 100644 --- a/setup.py +++ b/setup.py @@ -23,7 +23,7 @@ test_requires = [ ] setup(name='garminexport', - version='0.3.0', + version='0.4.0', description='Garmin Connect activity exporter and backup tool', long_description=long_description, long_description_content_type='text/markdown',