Compare commits

..

36 Commits

Author SHA1 Message Date
Markus Däschler
6fde77ddf8 Merge remote-tracking branch 'remotes/github/master' 2022-03-05 20:05:23 +01:00
Peter Gardfjäll
c2a7bf92a0 bump version: 0.4.0 2021-05-09 08:18:47 +02:00
Peter Gardfjäll
33b8354867 support customizing the 'User-Agent' value 2021-05-09 08:09:38 +02:00
Peter Gardfjäll
cbe9e74704 bump dependency versions
This bumps urllib3 from 1.26.3 to 1.26.4 since 1.26.3 was flagged by dependabot.
2021-05-09 07:28:08 +02:00
Peter Gardfjäll
71bd0ed9b8 bump version: 0.2.0 -> 0.3.0 2021-02-25 11:21:54 +01:00
Peter Gardfjäll
b62e2ac1e5
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 <kristoffer.follesdal@bkk.no>
2021-02-25 11:19:51 +01:00
Peter Gardfjäll
3886c314c9 bump version: v0.1.0 -> v0.2.0 2020-11-07 08:01:50 +01:00
fachleitner
9072333a3c
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.
2020-11-07 07:58:22 +01:00
Dan Lenski
ec0b8e651d
gracefully handle duplicate and slow activity uploads
upload_activity: handle poll-and-wait for uploads that require long processing time
2020-10-07 06:01:57 +02:00
Carey Metcalfe
577f5ec3c1 Fix setting the activity name as the description 2020-10-05 06:45:32 +02:00
Peter Gardfjäll
3684ca0fb8 license badge 2020-04-11 15:15:59 +02:00
Peter Gardfjäll
88eac30e15 python version badge 2020-04-11 15:14:41 +02:00
Peter Gardfjäll
69dae3d94d PyPi badge 2020-04-11 15:13:23 +02:00
Stanislav Khrapov
e0474057dd Fix error in documentation about export_formats argument type. 2020-04-11 15:05:55 +02:00
Peter Gardfjäll
73c1559ba5 setup.py: minor fix 2020-04-08 20:41:36 +02:00
Peter Gardfjäll
92024f7400 travis: use pipenv 2020-04-08 20:31:26 +02:00
Peter Gardfjäll
0c95e4f50f drop support for (now sunset) Python 2 2020-04-08 20:29:29 +02:00
Peter Gardfjäll
3df00a89c9 call export_formats supported_export_formats
Co-authored-by: Stanislav Khrapov khrapovs@gmail.com
2020-04-08 20:25:16 +02:00
Peter Gardfjäll
f2225bb3b3 README: update with new script names and pipenv use 2020-04-08 20:25:16 +02:00
Peter Gardfjäll
94872ae543 switch from requirements.txt to pipenv/Pipfile 2020-04-08 20:25:16 +02:00
Peter Gardfjäll
e7fc7e970a setup.py: remove unused dependency on future and make requests dependency looser 2020-04-08 20:25:16 +02:00
Peter Gardfjäll
d3f8819dc7 update setup.py in preparation for PyPi release 2020-04-08 20:25:08 +02:00
Peter Gardfjäll
bcb02afbb3 update gitignore 2020-04-07 20:40:00 +02:00
Peter Gardfjäll
a0d6163c52 move all executable scripts under garminexport.cli 2020-04-07 20:38:22 +02:00
Peter Gardfjäll
54dad23e1c enable travis CI builds
Co-authored-by: Stanislav Khrapov khrapovs@gmail.com
2020-04-07 14:24:55 +02:00
Peter Gardfjäll
f0548d2928 add missing ignore_errors argument in call to incremental_backup
Co-authored-by: Stanislav Khrapov khrapovs@gmail.com
2020-04-07 14:15:30 +02:00
Peter Gardfjäll
c00ccd3887 Simplify log statements.
Make use of format specifiers in the logging library rather than relying on
string.format(), which makes log output unnecessarily verbose.
2020-04-07 12:49:44 +02:00
Stanislav Khrapov
4ee0cda314
cosmetic code cleanup
Co-authored-by: Stanislav Khrapov <stanislav.khrapov@dbschenker.com>
2020-03-08 20:05:56 +01:00
Stanislav Khrapov
8cd27fcb19
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 <stanislav.khrapov@dbschenker.com>
2020-03-08 17:19:19 +01:00
Peter Gardfjäll
847cb84a94
README: format descriptions in smaller font size 2019-10-12 08:29:28 +02:00
petergardfjall
41f8701c06 README: format descriptions in smaller font size 2019-10-12 08:27:24 +02:00
petergardfjall
c02e23fb3e README: add format descriptions 2019-10-12 08:24:13 +02:00
Peter Gardfjäll
900584ba67
Merge pull request #34 from cwygoda/patch/relax-dependencies
Relax dependencies on minor level
2019-04-27 08:01:53 +02:00
Christian Wygoda
cf1b265b75 Relax dependencies on minor level 2019-04-25 01:15:37 +02:00
Peter Gardfjäll
94eb8f4e13
Merge pull request #33 from cwygoda/patch/relax-dependencies
Relax dependency version to use compatible releases
2019-04-24 20:19:01 +02:00
Christian Wygoda
4934ab3fe3 Relax dependency version to use compatible releases 2019-04-23 21:27:06 +02:00
22 changed files with 847 additions and 487 deletions

10
.gitignore vendored
View File

@ -1,4 +1,14 @@
*~ *~
*.pyc *.pyc
.ropeproject .ropeproject
.coverage
build/
/activities /activities
*.egg-info/
*.egg
*.py[cod]
__pycache__/
*.so
.venv/

8
.travis.yml Normal file
View File

@ -0,0 +1,8 @@
language: python
python:
- "3.7"
install:
- pip install pipenv
- pipenv install
script:
- python setup.py test

View File

@ -1,12 +1,6 @@
venv-py2: venv:
virtualenv venv.garminexport pipenv install
venv-py3:
python3 -m venv venv.garminexport
init:
pip install -r requirements.txt
clean: clean:
find -name '*~' -exec rm {} \; find -name '*~' -exec rm {} \;

14
Pipfile Normal file
View File

@ -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"

150
Pipfile.lock generated Normal file
View File

@ -0,0 +1,150 @@
{
"_meta": {
"hash": {
"sha256": "59e49afac1bfe0c2329a345793c399f46a2c57f14bf3abb6efa419c736c4009c"
},
"pipfile-spec": 6,
"requires": {},
"sources": [
{
"name": "pypi",
"url": "https://pypi.org/simple",
"verify_ssl": true
}
]
},
"default": {
"certifi": {
"hashes": [
"sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c",
"sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"
],
"version": "==2020.12.5"
},
"chardet": {
"hashes": [
"sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa",
"sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==4.0.0"
},
"garminexport": {
"editable": true,
"path": "."
},
"idna": {
"hashes": [
"sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
"sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.10"
},
"python-dateutil": {
"hashes": [
"sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c",
"sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"
],
"index": "pypi",
"version": "==2.8.1"
},
"requests": {
"hashes": [
"sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804",
"sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"
],
"index": "pypi",
"version": "==2.25.1"
},
"six": {
"hashes": [
"sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926",
"sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.16.0"
},
"urllib3": {
"hashes": [
"sha256:2f4da4594db7e1e110a944bb1b551fdf4e6c136ad42e4234131391e21eb5b0df",
"sha256:e7b021f7241115872f92f43c6508082facffbd1c048e3c6e2bb9c2a157e28937"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
"version": "==1.26.4"
}
},
"develop": {
"coverage": {
"hashes": [
"sha256:08907593569fe59baca0bf152c43f3863201efb6113ecb38ce7e97ce339805a6",
"sha256:0be0f1ed45fc0c185cfd4ecc19a1d6532d72f86a2bac9de7e24541febad72650",
"sha256:141f08ed3c4b1847015e2cd62ec06d35e67a3ac185c26f7635f4406b90afa9c5",
"sha256:19e4df788a0581238e9390c85a7a09af39c7b539b29f25c89209e6c3e371270d",
"sha256:23cc09ed395b03424d1ae30dcc292615c1372bfba7141eb85e11e50efaa6b351",
"sha256:245388cda02af78276b479f299bbf3783ef0a6a6273037d7c60dc73b8d8d7755",
"sha256:331cb5115673a20fb131dadd22f5bcaf7677ef758741312bee4937d71a14b2ef",
"sha256:386e2e4090f0bc5df274e720105c342263423e77ee8826002dcffe0c9533dbca",
"sha256:3a794ce50daee01c74a494919d5ebdc23d58873747fa0e288318728533a3e1ca",
"sha256:60851187677b24c6085248f0a0b9b98d49cba7ecc7ec60ba6b9d2e5574ac1ee9",
"sha256:63a9a5fc43b58735f65ed63d2cf43508f462dc49857da70b8980ad78d41d52fc",
"sha256:6b62544bb68106e3f00b21c8930e83e584fdca005d4fffd29bb39fb3ffa03cb5",
"sha256:6ba744056423ef8d450cf627289166da65903885272055fb4b5e113137cfa14f",
"sha256:7494b0b0274c5072bddbfd5b4a6c6f18fbbe1ab1d22a41e99cd2d00c8f96ecfe",
"sha256:826f32b9547c8091679ff292a82aca9c7b9650f9fda3e2ca6bf2ac905b7ce888",
"sha256:93715dffbcd0678057f947f496484e906bf9509f5c1c38fc9ba3922893cda5f5",
"sha256:9a334d6c83dfeadae576b4d633a71620d40d1c379129d587faa42ee3e2a85cce",
"sha256:af7ed8a8aa6957aac47b4268631fa1df984643f07ef00acd374e456364b373f5",
"sha256:bf0a7aed7f5521c7ca67febd57db473af4762b9622254291fbcbb8cd0ba5e33e",
"sha256:bf1ef9eb901113a9805287e090452c05547578eaab1b62e4ad456fcc049a9b7e",
"sha256:c0afd27bc0e307a1ffc04ca5ec010a290e49e3afbe841c5cafc5c5a80ecd81c9",
"sha256:dd579709a87092c6dbee09d1b7cfa81831040705ffa12a1b248935274aee0437",
"sha256:df6712284b2e44a065097846488f66840445eb987eb81b3cc6e4149e7b6982e1",
"sha256:e07d9f1a23e9e93ab5c62902833bf3e4b1f65502927379148b6622686223125c",
"sha256:e2ede7c1d45e65e209d6093b762e98e8318ddeff95317d07a27a2140b80cfd24",
"sha256:e4ef9c164eb55123c62411f5936b5c2e521b12356037b6e1c2617cef45523d47",
"sha256:eca2b7343524e7ba246cab8ff00cab47a2d6d54ada3b02772e908a45675722e2",
"sha256:eee64c616adeff7db37cc37da4180a3a5b6177f5c46b187894e633f088fb5b28",
"sha256:ef824cad1f980d27f26166f86856efe11eff9912c4fed97d3804820d43fa550c",
"sha256:efc89291bd5a08855829a3c522df16d856455297cf35ae827a37edac45f466a7",
"sha256:fa964bae817babece5aa2e8c1af841bebb6d0b9add8e637548809d040443fee0",
"sha256:ff37757e068ae606659c28c3bd0d923f9d29a85de79bf25b2b34b148473b5025"
],
"index": "pypi",
"version": "==4.5.4"
},
"mock": {
"hashes": [
"sha256:5ce3c71c5545b472da17b72268978914d0252980348636840bd34a00b5cc96c1",
"sha256:b158b6df76edd239b8208d481dc46b6afd45a846b7812ff0ce58971cf5bc8bba"
],
"index": "pypi",
"version": "==2.0.0"
},
"nose": {
"hashes": [
"sha256:9ff7c6cc443f8c51994b34a667bbcf45afd6d945be7477b52e97516fd17c53ac",
"sha256:dadcddc0aefbf99eea214e0f1232b94f2fa9bd98fa8353711dacb112bfcbbb2a",
"sha256:f1bffef9cbc82628f6e7d7b40d7e255aefaa1adb6a1b1d26c69a8b79e6208a98"
],
"index": "pypi",
"version": "==1.3.7"
},
"pbr": {
"hashes": [
"sha256:42df03e7797b796625b1029c0400279c7c34fd7df24a7d7818a1abb5b38710dd",
"sha256:c68c661ac5cc81058ac94247278eeda6d2e6aecb3e227b0387c30d277e7ef8d4"
],
"markers": "python_version >= '2.6'",
"version": "==5.6.0"
},
"six": {
"hashes": [
"sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926",
"sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.16.0"
}
}
}

156
README.md
View File

@ -1,104 +1,122 @@
Garmin Connect activity backup tool [![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)
``garminbackup.py`` is a program that downloads activities for a ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/garminexport)
given [Garmin Connect](http://connect.garmin.com/) account and stores ![PyPI - License](https://img.shields.io/pypi/l/garminexport)
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 do incremental backups of your account. That is, the script will only download
activities that haven't already been downloaded to the backup directory. 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 # Installation
projects that need to communicate over the Garmin Connect API. See the `garminexport` is available on [PyPi](https://pypi.org/) and can be installed
Library Import section below for more details. with [pip](http://pip.readthedocs.org):
pip install garminexport
It requires Python 3.5+.
Prerequisites # Usage
=============
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/).
Getting started ## Prerequisites
=============== To be of any use you need to register an account at [Garmin
Create and activate a new virtual environment to create an isolated development Connect](http://connect.garmin.com/) and populate it with some activities.
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
## 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 <username or email> garmin-backup --backup-dir=activities <username or email>
Once started, the program will prompt you for your account password and then Once started, the program will prompt you for your account password and then log
log in to your Garmin Connect account to download activities to the specified in to your Garmin Connect account to download activities to the specified backup
backup directory on your machine. The program will only download activities directory on your machine. The program will only download activities that aren't
that aren't already in the backup directory. already in the backup directory.
Activities can be exported in any of the formats outlined below. Note that 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 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: 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) <sub>[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.</sub>
- ``tcx``: an activity TCX file (XML). - `tcx`: an activity TCX file (XML).
*Note: a ``.tcx`` file may not always be possible to export, for example *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 if an activity was uploaded in gpx format. In that case, Garmin won't try
to synthesize a tcx file.* to synthesize a tcx file.*
- ``fit``: activity FIT file (binary format). <sub>[TCX](https://en.wikipedia.org/wiki/Training_Center_XML) (Training
*Note: a ``.fit`` file may not always be possible to export, for example 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).</sub>
- `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.* if an activity was entered manually rather than imported from a Garmin device.*
All files are written to the same directory (``activities/`` by default). <sub>The [FIT](https://www.thisisant.com/resources/fit/) format is the
Each activity file is prefixed by its upload timestamp and its activity id. "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.</sub>
- `json_summary`: activity summary file (JSON).
<sub>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.</sub>
- `json_details`: activity details file (JSON).
<sub>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.</sub>
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 - `garmin-get-activity`: download a single Garmin Connect activity. Run with
============== `--help`for more details.
To install the development version of this library in your local Python - `garmin-upload-activity`: uplad a single Garmin Connect activity file (`.fit`,
environment, run: `.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 ## As a library
to your list of dependencies:
`-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

View File

@ -1,124 +0,0 @@
#! /usr/bin/env python
"""Performs (incremental) backups of activities for a given Garmin Connect
account.
The activities are stored in a local directory on the user's computer.
The backups are incremental, meaning that only activities that aren't already
stored in the backup directory will be downloaded.
"""
import argparse
from datetime import timedelta
import getpass
from garminexport.garminclient import GarminClient
import garminexport.backup
from garminexport.backup import export_formats
from garminexport.retryer import (
Retryer, ExponentialBackoffDelayStrategy, MaxRetriesStopStrategy)
import logging
import os
import re
import sys
import traceback
logging.basicConfig(
level=logging.INFO, format="%(asctime)-15s [%(levelname)s] %(message)s")
log = logging.getLogger(__name__)
LOG_LEVELS = {
"DEBUG": logging.DEBUG,
"INFO": logging.INFO,
"WARNING": logging.WARNING,
"ERROR": logging.ERROR
}
"""Command-line (string-based) log-level mapping to logging module levels."""
DEFAULT_MAX_RETRIES = 7
"""The default maximum number of retries to make when fetching a single activity."""
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description=(
"Performs incremental backups of activities for a "
"given Garmin Connect account. Only activities that "
"aren't already stored in the backup directory will "
"be downloaded."))
# positional args
parser.add_argument(
"username", metavar="<username>", type=str, help="Account user name.")
# optional args
parser.add_argument(
"--password", type=str, help="Account password.")
parser.add_argument(
"--backup-dir", metavar="DIR", type=str,
help=("Destination directory for downloaded activities. Default: "
"./activities/"), default=os.path.join(".", "activities"))
parser.add_argument(
"--log-level", metavar="LEVEL", type=str,
help=("Desired log output level (DEBUG, INFO, WARNING, ERROR). "
"Default: INFO."), default="INFO")
parser.add_argument(
"-f", "--format", choices=export_formats,
default=None, action='append',
help=("Desired output formats ("+', '.join(export_formats)+"). "
"Default: ALL."))
parser.add_argument(
"-E", "--ignore-errors", action='store_true',
help="Ignore errors and keep going. Default: FALSE")
parser.add_argument(
"--max-retries", metavar="NUM", default=DEFAULT_MAX_RETRIES,
type=int, help="The maximum number of retries to make on failed attempts to fetch an activity. Exponential backoff will be used, meaning that the delay between successive attempts will double with every retry, starting at one second. DEFAULT: %d" % DEFAULT_MAX_RETRIES)
args = parser.parse_args()
if not args.log_level in LOG_LEVELS:
raise ValueError("Illegal log-level: {}".format(args.log_level))
# if no --format was specified, all formats are to be backed up
args.format = args.format if args.format else export_formats
log.info("backing up formats: %s", ", ".join(args.format))
logging.root.setLevel(LOG_LEVELS[args.log_level])
try:
if not os.path.isdir(args.backup_dir):
os.makedirs(args.backup_dir)
if not args.password:
args.password = getpass.getpass("Enter password: ")
# set up a retryer that will handle retries of failed activity
# downloads
retryer = Retryer(
delay_strategy=ExponentialBackoffDelayStrategy(
initial_delay=timedelta(seconds=1)),
stop_strategy=MaxRetriesStopStrategy(args.max_retries))
with GarminClient(args.username, args.password) as client:
# get all activity ids and timestamps from Garmin account
log.info("scanning activities for %s ...", args.username)
activities = set(retryer.call(client.list_activities))
log.info("account has a total of %d activities", len(activities))
missing_activities = garminexport.backup.need_backup(
activities, args.backup_dir, args.format)
backed_up = activities - missing_activities
log.info("%s contains %d backed up activities",
args.backup_dir, len(backed_up))
log.info("activities that aren't backed up: %d",
len(missing_activities))
for index, activity in enumerate(missing_activities):
id, start = activity
log.info("backing up activity %d from %s (%d out of %d) ..." % (id, start, index+1, len(missing_activities)))
try:
garminexport.backup.download(
client, activity, retryer, args.backup_dir,
args.format)
except Exception as e:
log.error(u"failed with exception: %s", e)
if not args.ignore_errors:
raise
except Exception as e:
exc_type, exc_value, exc_traceback = sys.exc_info()
log.error(u"failed with exception: %s", str(e))

View File

@ -11,10 +11,9 @@
<OutputPath>.</OutputPath> <OutputPath>.</OutputPath>
<ProjectTypeGuids>{888888a0-9f3d-457c-b088-3a5042f75d52}</ProjectTypeGuids> <ProjectTypeGuids>{888888a0-9f3d-457c-b088-3a5042f75d52}</ProjectTypeGuids>
<LaunchProvider>Standard Python launcher</LaunchProvider> <LaunchProvider>Standard Python launcher</LaunchProvider>
<InterpreterId /> <InterpreterId>MSBuild|env|$(MSBuildProjectFullPath)</InterpreterId>
<IsWindowsApplication>False</IsWindowsApplication> <IsWindowsApplication>False</IsWindowsApplication>
<CommandLineArguments> <CommandLineArguments>markus@daeschler.name</CommandLineArguments>
</CommandLineArguments>
<EnableNativeCodeDebugging>False</EnableNativeCodeDebugging> <EnableNativeCodeDebugging>False</EnableNativeCodeDebugging>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition="'$(Configuration)' == 'Debug'" /> <PropertyGroup Condition="'$(Configuration)' == 'Debug'" />
@ -44,5 +43,16 @@
<Folder Include="samples" /> <Folder Include="samples" />
<Folder Include="tests" /> <Folder Include="tests" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Interpreter Include="env\">
<Id>env</Id>
<Version>3.7</Version>
<Description>env (Python 3.7 (64-bit))</Description>
<InterpreterPath>Scripts\python.exe</InterpreterPath>
<WindowsInterpreterPath>Scripts\pythonw.exe</WindowsInterpreterPath>
<PathEnvironmentVariable>PYTHONPATH</PathEnvironmentVariable>
<Architecture>X64</Architecture>
</Interpreter>
</ItemGroup>
<Import Project="$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\Python Tools\Microsoft.PythonTools.targets" /> <Import Project="$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\Python Tools\Microsoft.PythonTools.targets" />
</Project> </Project>

View File

@ -2,14 +2,13 @@
""" """
import codecs import codecs
import json import json
from datetime import datetime
import dateutil.parser
import logging import logging
import os import os
from datetime import datetime
log = logging.getLogger(__name__) 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.""" """The range of supported export formats for activities."""
format_suffix = { format_suffix = {
@ -21,7 +20,6 @@ format_suffix = {
} }
"""A table that maps export formats to their file format extensions.""" """A table that maps export formats to their file format extensions."""
not_found_file = ".not_found" not_found_file = ".not_found"
"""A file that lists all tried but failed export attempts. The lines in """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. the file are the would-have-been file names, had the exports been successful.
@ -65,6 +63,9 @@ def need_backup(activities, backup_dir, export_formats=None):
:type activities: list of tuples of `(int, datetime)` :type activities: list of tuples of `(int, datetime)`
:param backup_dir: Destination directory for exported activities. :param backup_dir: Destination directory for exported activities.
:type backup_dir: str :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. :return: All activities that need to be backed up.
:rtype: set of tuples of `(int, datetime)` :rtype: set of tuples of `(int, datetime)`
""" """
@ -87,12 +88,10 @@ def _not_found_activities(backup_dir):
if os.path.isfile(_not_found): if os.path.isfile(_not_found):
with open(_not_found, mode="r") as f: with open(_not_found, mode="r") as f:
failed_activities = [line.strip() for line in f.readlines()] failed_activities = [line.strip() for line in f.readlines()]
log.debug("%d tried but failed activities in %s", log.debug("%d tried but failed activities in %s", len(failed_activities), _not_found)
len(failed_activities), _not_found)
return failed_activities return failed_activities
def download(client, activity, retryer, backup_dir, export_formats=None): def download(client, activity, retryer, backup_dir, export_formats=None):
"""Exports a Garmin Connect activity to a given set of formats """Exports a Garmin Connect activity to a given set of formats
and saves the resulting file(s) to a given backup directory. and saves the resulting file(s) to a given backup directory.
@ -124,25 +123,21 @@ def download(client, activity, retryer, backup_dir, export_formats=None):
dest = os.path.join( dest = os.path.join(
backup_dir, export_filename(activity, 'json_summary')) backup_dir, export_filename(activity, 'json_summary'))
with codecs.open(dest, encoding="utf-8", mode="w") as f: with codecs.open(dest, encoding="utf-8", mode="w") as f:
f.write(json.dumps( f.write(json.dumps(activity_summary, ensure_ascii=False, indent=4))
activity_summary, ensure_ascii=False, indent=4))
if 'json_details' in export_formats: if 'json_details' in export_formats:
log.debug("getting json details for %s", id) log.debug("getting json details for %s", id)
activity_details = retryer.call(client.get_activity_details, id) activity_details = retryer.call(client.get_activity_details, id)
dest = os.path.join( dest = os.path.join(backup_dir, export_filename(activity, 'json_details'))
backup_dir, export_filename(activity, 'json_details'))
with codecs.open(dest, encoding="utf-8", mode="w") as f: with codecs.open(dest, encoding="utf-8", mode="w") as f:
f.write(json.dumps( f.write(json.dumps(activity_details, ensure_ascii=False, indent=4))
activity_details, ensure_ascii=False, indent=4))
not_found_path = os.path.join(backup_dir, not_found_file) not_found_path = os.path.join(backup_dir, not_found_file)
with open(not_found_path, mode="a") as not_found: with open(not_found_path, mode="a") as not_found:
if 'gpx' in export_formats: if 'gpx' in export_formats:
log.debug("getting gpx for %s", id) log.debug("getting gpx for %s", id)
activity_gpx = retryer.call(client.get_activity_gpx, id) activity_gpx = retryer.call(client.get_activity_gpx, id)
dest = os.path.join( dest = os.path.join(backup_dir, export_filename(activity, 'gpx'))
backup_dir, export_filename(activity, 'gpx'))
if activity_gpx is None: if activity_gpx is None:
not_found.write(os.path.basename(dest) + "\n") not_found.write(os.path.basename(dest) + "\n")
else: else:
@ -152,8 +147,7 @@ def download(client, activity, retryer, backup_dir, export_formats=None):
if 'tcx' in export_formats: if 'tcx' in export_formats:
log.debug("getting tcx for %s", id) log.debug("getting tcx for %s", id)
activity_tcx = retryer.call(client.get_activity_tcx, id) activity_tcx = retryer.call(client.get_activity_tcx, id)
dest = os.path.join( dest = os.path.join(backup_dir, export_filename(activity, 'tcx'))
backup_dir, export_filename(activity, 'tcx'))
if activity_tcx is None: if activity_tcx is None:
not_found.write(os.path.basename(dest) + "\n") not_found.write(os.path.basename(dest) + "\n")
else: else:

View File

View File

@ -0,0 +1,87 @@
"""This script performs backups of activities for a Garmin Connect account. The
activities are stored in a local directory on the user's computer. The backups
are incremental, meaning that only activities that aren't already stored in the
backup directory will be downloaded.
"""
import argparse
import logging
import os
from garminexport.backup import supported_export_formats
from garminexport.incremental_backup import incremental_backup
from garminexport.logging_config import LOG_LEVELS
logging.basicConfig(level=logging.INFO, format="%(asctime)-15s [%(levelname)s] %(message)s")
log = logging.getLogger(__name__)
DEFAULT_MAX_RETRIES = 7
"""The default maximum number of retries to make when fetching a single activity."""
DEFAULT_USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36'
"""The default `User-Agent` to use for HTTP requests when none is supplied by
the user.
"""
def parse_args() -> argparse.Namespace:
"""Parse CLI arguments.
:return: Namespace object holding parsed arguments as attributes.
This object may be directly used by garminexport/garminbackup.py.
"""
parser = argparse.ArgumentParser(
prog="garminbackup",
description=(
"Performs incremental backups of activities for a "
"given Garmin Connect account. Only activities that "
"aren't already stored in the backup directory will "
"be downloaded."))
# positional args
parser.add_argument(
"username", metavar="<username>", type=str, help="Account user name.")
# optional args
parser.add_argument(
"--password", type=str, help="Account password.")
parser.add_argument(
"--backup-dir", metavar="DIR", type=str,
help="Destination directory for downloaded activities. Default: ./activities/",
default=os.path.join(".", "activities"))
parser.add_argument(
"--log-level", metavar="LEVEL", type=str,
help="Desired log output level (DEBUG, INFO, WARNING, ERROR). Default: INFO.",
default="INFO")
parser.add_argument(
"-f", "--format", choices=supported_export_formats,
default=None, action='append',
help="Desired output formats ({}). Default: ALL.".format(', '.join(supported_export_formats)))
parser.add_argument(
"-E", "--ignore-errors", action='store_true',
help="Ignore errors and keep going. Default: FALSE")
parser.add_argument(
"--max-retries", metavar="NUM", default=DEFAULT_MAX_RETRIES,
type=int,
help=("The maximum number of retries to make on failed attempts to fetch an activity. "
"Exponential backoff will be used, meaning that the delay between successive attempts "
"will double with every retry, starting at one second. DEFAULT: {}").format(DEFAULT_MAX_RETRIES))
parser.add_argument(
"--user-agent", type=str, default=DEFAULT_USER_AGENT,
help="A value to use for the `User-Agent` request header. Use an authentic browser agent string to prevent being blocked by Garmin. A tool such as `user_agent` (`ua`) can be used to generate such values.")
return parser.parse_args()
def main():
args = parse_args()
logging.root.setLevel(LOG_LEVELS[args.log_level])
try:
incremental_backup(username=args.username,
password=args.password,
user_agent_fn=lambda:DEFAULT_USER_AGENT,
backup_dir=args.backup_dir,
export_formats=args.format,
ignore_errors=args.ignore_errors,
max_retries=args.max_retries)
except Exception as e:
log.error("failed with exception: {}".format(e))

View File

@ -0,0 +1,79 @@
#! /usr/bin/env python
"""A program that downloads one particular activity from a given Garmin
Connect account and stores it locally on the user's computer.
"""
import argparse
import getpass
import logging
import os
from datetime import timedelta
import dateutil.parser
import garminexport.backup
from garminexport.garminclient import GarminClient
from garminexport.logging_config import LOG_LEVELS
from garminexport.retryer import Retryer, ExponentialBackoffDelayStrategy, MaxRetriesStopStrategy
logging.basicConfig(level=logging.INFO, format="%(asctime)-15s [%(levelname)s] %(message)s")
log = logging.getLogger(__name__)
def main():
parser = argparse.ArgumentParser(
description="Downloads one particular activity for a given Garmin Connect account.")
# positional args
parser.add_argument(
"username", metavar="<username>", type=str, help="Account user name.")
parser.add_argument(
"activity", metavar="<activity>", type=int, help="Activity ID.")
parser.add_argument(
"format", metavar="<format>", type=str,
help="Export format (one of: {}).".format(garminexport.backup.supported_export_formats))
# optional args
parser.add_argument(
"--password", type=str, help="Account password.")
parser.add_argument(
"--destination", metavar="DIR", type=str,
help="Destination directory for downloaded activity. Default: ./activities/",
default=os.path.join(".", "activities"))
parser.add_argument(
"--log-level", metavar="LEVEL", type=str,
help="Desired log output level (DEBUG, INFO, WARNING, ERROR). Default: INFO.",
default="INFO")
args = parser.parse_args()
if args.log_level not in LOG_LEVELS:
raise ValueError("Illegal log-level argument: {}".format(args.log_level))
if args.format not in garminexport.backup.supported_export_formats:
raise ValueError(
"Unrecognized export format: '{}'. Must be one of {}".format(
args.format, garminexport.backup.supported_export_formats))
logging.root.setLevel(LOG_LEVELS[args.log_level])
try:
if not os.path.isdir(args.destination):
os.makedirs(args.destination)
if not args.password:
args.password = getpass.getpass("Enter password: ")
with GarminClient(args.username, args.password) as client:
log.info("fetching activity %s ...", args.activity)
summary = client.get_activity_summary(args.activity)
# set up a retryer that will handle retries of failed activity downloads
retryer = Retryer(
delay_strategy=ExponentialBackoffDelayStrategy(initial_delay=timedelta(seconds=1)),
stop_strategy=MaxRetriesStopStrategy(5))
start_time = dateutil.parser.parse(summary["summaryDTO"]["startTimeGMT"])
garminexport.backup.download(
client, (args.activity, start_time), retryer, args.destination, export_formats=[args.format])
except Exception as e:
log.error("failed with exception: %s", e)
raise

View File

@ -1,30 +1,21 @@
#! /usr/bin/env python #! /usr/bin/env python
"""A program that uploads an activity file to a Garmin """A program that uploads an activity file to a Garmin Connect account.
Connect account.
""" """
import argparse import argparse
import getpass import getpass
from garminexport.garminclient import GarminClient
import logging import logging
import sys
import traceback
logging.basicConfig( from garminexport.garminclient import GarminClient
level=logging.INFO, format="%(asctime)-15s [%(levelname)s] %(message)s") from garminexport.logging_config import LOG_LEVELS
logging.basicConfig(level=logging.INFO, format="%(asctime)-15s [%(levelname)s] %(message)s")
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
LOG_LEVELS = {
"DEBUG": logging.DEBUG,
"INFO": logging.INFO,
"WARNING": logging.WARNING,
"ERROR": logging.ERROR
}
"""Command-line (string-based) log-level mapping to logging module levels."""
if __name__ == "__main__":
def main():
parser = argparse.ArgumentParser( 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 # positional args
parser.add_argument( parser.add_argument(
"username", metavar="<username>", type=str, help="Account user name.") "username", metavar="<username>", type=str, help="Account user name.")
@ -45,31 +36,34 @@ if __name__ == "__main__":
'-T', '--type', help="Override activity type (running, cycling, walking, hiking, strength_training, etc.)") '-T', '--type', help="Override activity type (running, cycling, walking, hiking, strength_training, etc.)")
parser.add_argument( parser.add_argument(
"--log-level", metavar="LEVEL", type=str, "--log-level", metavar="LEVEL", type=str,
help=("Desired log output level (DEBUG, INFO, WARNING, ERROR). " help="Desired log output level (DEBUG, INFO, WARNING, ERROR). Default: INFO.",
"Default: INFO."), default="INFO") default="INFO")
args = parser.parse_args() 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.") 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( if args.log_level not in LOG_LEVELS:
args.log_level)) raise ValueError("Illegal log-level argument: {}".format(args.log_level))
logging.root.setLevel(LOG_LEVELS[args.log_level]) logging.root.setLevel(LOG_LEVELS[args.log_level])
try: try:
if not args.password: if not args.password:
args.password = getpass.getpass("Enter password: ") args.password = getpass.getpass("Enter password: ")
with GarminClient(args.username, args.password) as client: with GarminClient(args.username, args.password) as client:
for activity in args.activity: for activity in args.activity:
log.info("uploading activity file {} ...".format(activity.name)) log.info("uploading activity file %s ...", activity.name)
try: 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: except Exception as e:
log.error("upload failed: {!r}".format(e)) log.error("upload failed: {!r}".format(e))
else: 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:
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: %s", e)
raise

View File

@ -6,16 +6,20 @@ parts of the Garmin Connect REST API.
import json import json
import logging import logging
import os import os
import os.path
import re import re
import requests
from io import BytesIO
import sys import sys
import zipfile import zipfile
from datetime import timedelta, datetime
from builtins import range
from functools import wraps
from io import BytesIO
import dateutil import dateutil
import dateutil.parser import dateutil.parser
import os.path import requests
from functools import wraps
from builtins import range from garminexport.retryer import Retryer, ExponentialBackoffDelayStrategy, MaxRetriesStopStrategy
# #
# Note: For more detailed information about the API services # Note: For more detailed information about the API services
@ -37,20 +41,25 @@ log = logging.getLogger(__name__)
# reduce logging noise from requests library # reduce logging noise from requests library
logging.getLogger("requests").setLevel(logging.ERROR) logging.getLogger("requests").setLevel(logging.ERROR)
SSO_LOGIN_URL = "https://sso.garmin.com/sso/signin" SSO_LOGIN_URL = "https://sso.garmin.com/sso/login"
"""The Garmin Connect Single-Sign On login URL.""" """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): def require_session(client_function):
"""Decorator that is used to annotate :class:`GarminClient` """Decorator that is used to annotate :class:`GarminClient`
methods that need an authenticated session before being called. methods that need an authenticated session before being called.
""" """
@wraps(client_function) @wraps(client_function)
def check_session(*args, **kwargs): def check_session(*args, **kwargs):
client_object = args[0] client_object = args[0]
if not client_object.session: if not client_object.session:
raise Exception("Attempt to use GarminClient without being connected. Call connect() before first use.'") raise Exception("Attempt to use GarminClient without being connected. Call connect() before first use.'")
return client_function(*args, **kwargs) return client_function(*args, **kwargs)
return check_session return check_session
@ -72,18 +81,27 @@ class GarminClient(object):
""" """
def __init__(self, username, password): def __init__(self, username, password, user_agent_fn=None):
"""Initialize a :class:`GarminClient` instance. """Initialize a :class:`GarminClient` instance.
:param username: Garmin Connect user name or email address. :param username: Garmin Connect user name or email address.
:type username: str :type username: str
:param password: Garmin Connect account password. :param password: Garmin Connect account password.
:type password: str :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.username = username
self.password = password self.password = password
self._user_agent_fn = user_agent_fn
self.session = None self.session = None
def __enter__(self): def __enter__(self):
self.connect() self.connect()
return self return self
@ -102,39 +120,69 @@ class GarminClient(object):
def _authenticate(self): def _authenticate(self):
log.info("authenticating user ...") log.info("authenticating user ...")
form_data = { form_data = {
"username": self.username, "username": self.username,
"password": self.password, "password": self.password,
"embed": "false" "embed": "false",
"_csrf": self._get_csrf_token(),
} }
request_params = { headers = {
"service": "https://connect.garmin.com/modern" '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( 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) log.debug("got auth response: %s", auth_response.text)
if auth_response.status_code != 200: if auth_response.status_code != 200:
raise ValueError( raise ValueError("authentication failure: did you enter valid credentials?")
"authentication failure: did you enter valid credentials?") auth_ticket_url = self._extract_auth_ticket_url(auth_response.text)
auth_ticket_url = self._extract_auth_ticket_url(
auth_response.text)
log.debug("auth ticket url: '%s'", auth_ticket_url) log.debug("auth ticket url: '%s'", auth_ticket_url)
log.info("claiming auth ticket ...") log.info("claiming auth ticket ...")
response = self.session.get(auth_ticket_url) response = self.session.get(auth_ticket_url)
if response.status_code != 200: if response.status_code != 200:
raise RuntimeError( raise RuntimeError(
"auth failure: failed to claim auth ticket: %s: %d\n%s" % "auth failure: failed to claim auth ticket: {}: {}\n{}".format(
(auth_ticket_url, response.status_code, response.text)) auth_ticket_url, response.status_code, response.text))
# appears like we need to touch base with the old API to initiate # appears like we need to touch base with the main page to complete the
# some form of legacy session. otherwise certain downloads will fail. # login ceremony.
self.session.get('https://connect.garmin.com/legacy/session') 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'<input type="hidden" name="_csrf" value="(\w+)"',
resp.content.decode('utf-8'))
if not csrf_token:
raise ValueError("auth failure: no CSRF token in {}".format(SSO_LOGIN_URL))
return csrf_token.group(1)
def _extract_auth_ticket_url(self, auth_response): def _auth_params(self):
"""A set of request query parameters that need to be present for Garmin to
accept our login attempt.
"""
return {
"service": "https://connect.garmin.com/modern/",
"gauthHost": "https://sso.garmin.com/sso",
}
@staticmethod
def _extract_auth_ticket_url(auth_response):
"""Extracts an authentication ticket URL from the response of an """Extracts an authentication ticket URL from the response of an
authentication form submission. The auth ticket URL is typically authentication form submission. The auth ticket URL is typically
of form: of form:
@ -143,22 +191,19 @@ class GarminClient(object):
:param auth_response: HTML response from an auth form submission. :param auth_response: HTML response from an auth form submission.
""" """
match = re.search( match = re.search(r'response_url\s*=\s*"(https:[^"]+)"', auth_response)
r'response_url\s*=\s*"(https:[^"]+)"', auth_response)
if not match: if not match:
raise RuntimeError( raise RuntimeError(
"auth failure: unable to extract auth ticket URL. did you provide a correct username/password?") "auth failure: unable to extract auth ticket URL. did you provide a correct username/password?")
auth_ticket_url = match.group(1).replace("\\", "") auth_ticket_url = match.group(1).replace("\\", "")
return auth_ticket_url return auth_ticket_url
@require_session @require_session
def list_activities(self): def list_activities(self):
"""Return all activity ids stored by the logged in user, along """Return all activity ids stored by the logged in user, along
with their starting timestamps. with their starting timestamps.
:returns: The full list of activity identifiers (along with their :returns: The full list of activity identifiers (along with their starting timestamps).
starting timestamps).
:rtype: tuples of (int, datetime) :rtype: tuples of (int, datetime)
""" """
ids = [] ids = []
@ -178,28 +223,24 @@ class GarminClient(object):
timestamps) starting at a given index, with index 0 being the user's timestamps) starting at a given index, with index 0 being the user's
most recently registered activity. most recently registered activity.
Should the index be out of bounds or the account empty, an empty Should the index be out of bounds or the account empty, an empty list is returned.
list is returned.
:param start_index: The index of the first activity to retrieve. :param start_index: The index of the first activity to retrieve.
:type start_index: int :type start_index: int
:param max_limit: The (maximum) number of activities to retrieve. :param max_limit: The (maximum) number of activities to retrieve.
:type max_limit: int :type max_limit: int
:returns: A list of activity identifiers (along with their :returns: A list of activity identifiers (along with their starting timestamps).
starting timestamps).
:rtype: tuples of (int, datetime) :rtype: tuples of (int, datetime)
""" """
log.debug("fetching activities {} through {} ...".format( log.debug("fetching activities %d through %d ...", start_index, start_index + max_limit - 1)
start_index, start_index+max_limit-1))
response = self.session.get( response = self.session.get(
"https://connect.garmin.com/modern/proxy/activitylist-service/activities/search/activities", "https://connect.garmin.com/proxy/activitylist-service/activities/search/activities",
params={"start": start_index, "limit": max_limit}) params={"start": start_index, "limit": max_limit})
if response.status_code != 200: if response.status_code != 200:
raise Exception( raise Exception(
u"failed to fetch activities {} to {} types: {}\n{}".format( u"failed to fetch activities {} to {} types: {}\n{}".format(
start_index, (start_index+max_limit-1), start_index, (start_index + max_limit - 1), response.status_code, response.text))
response.status_code, response.text))
activities = json.loads(response.text) activities = json.loads(response.text)
if not activities: if not activities:
# index out of bounds or empty account # index out of bounds or empty account
@ -212,25 +253,25 @@ class GarminClient(object):
# make sure UTC timezone gets set # make sure UTC timezone gets set
timestamp_utc = timestamp_utc.replace(tzinfo=dateutil.tz.tzutc()) 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))) log.debug("got %d activities.", len(entries))
return entries return entries
@require_session @require_session
def get_activity_summary(self, activity_id): def get_activity_summary(self, activity_id):
"""Return a summary about a given activity. The """Return a summary about a given activity.
summary contains several statistics, such as duration, GPS starting The summary contains several statistics, such as duration, GPS starting
point, GPS end point, elevation gain, max heart rate, max pace, max point, GPS end point, elevation gain, max heart rate, max pace, max speed, etc).
speed, etc).
:param activity_id: Activity identifier. :param activity_id: Activity identifier.
:type activity_id: int :type activity_id: int
:returns: The activity summary as a JSON dict. :returns: The activity summary as a JSON dict.
:rtype: 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/proxy/activity-service/activity/{}".format(activity_id))
if response.status_code != 200: if response.status_code != 200:
log.error(u"failed to fetch json summary for activity {}: {}\n{}".format( log.error(u"failed to fetch json summary for activity %s: %d\n%s",
activity_id, response.status_code, response.text)) activity_id, response.status_code, response.text)
raise Exception(u"failed to fetch json summary for activity {}: {}\n{}".format( raise Exception(u"failed to fetch json summary for activity {}: {}\n{}".format(
activity_id, response.status_code, response.text)) activity_id, response.status_code, response.text))
return json.loads(response.text) return json.loads(response.text)
@ -247,7 +288,8 @@ class GarminClient(object):
:rtype: dict :rtype: dict
""" """
# mounted at xml or json depending on result encoding # 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/proxy/activity-service/activity/{}/details".format(activity_id))
if response.status_code != 200: if response.status_code != 200:
raise Exception(u"failed to fetch json activityDetails for {}: {}\n{}".format( raise Exception(u"failed to fetch json activityDetails for {}: {}\n{}".format(
activity_id, response.status_code, response.text)) activity_id, response.status_code, response.text))
@ -266,7 +308,8 @@ class GarminClient(object):
or ``None`` if the activity couldn't be exported to GPX. or ``None`` if the activity couldn't be exported to GPX.
:rtype: str :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/proxy/download-service/export/gpx/activity/{}".format(activity_id))
# An alternate URL that seems to produce the same results # An alternate URL that seems to produce the same results
# and is the one used when exporting through the Garmin # and is the one used when exporting through the Garmin
# Connect web page. # Connect web page.
@ -282,7 +325,6 @@ class GarminClient(object):
activity_id, response.status_code, response.text)) activity_id, response.status_code, response.text))
return response.text return response.text
@require_session @require_session
def get_activity_tcx(self, activity_id): def get_activity_tcx(self, activity_id):
"""Return a TCX (Training Center XML) representation of a """Return a TCX (Training Center XML) representation of a
@ -298,7 +340,8 @@ class GarminClient(object):
:rtype: str :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/proxy/download-service/export/tcx/activity/{}".format(activity_id))
if response.status_code == 404: if response.status_code == 404:
return None return None
if response.status_code != 200: if response.status_code != 200:
@ -306,7 +349,6 @@ class GarminClient(object):
activity_id, response.status_code, response.text)) activity_id, response.status_code, response.text))
return response.text return response.text
def get_original_activity(self, activity_id): def get_original_activity(self, activity_id):
"""Return the original file that was uploaded for an activity. """Return the original file that was uploaded for an activity.
If the activity doesn't have any file source (for example, If the activity doesn't have any file source (for example,
@ -319,14 +361,15 @@ class GarminClient(object):
its contents, or :obj:`(None,None)` if no file is found. its contents, or :obj:`(None,None)` if no file is found.
:rtype: (str, str) :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/proxy/download-service/files/activity/{}".format(activity_id))
# A 404 (Not Found) response is a clear indicator of a missing .fit # A 404 (Not Found) response is a clear indicator of a missing .fit
# file. As of lately, the endpoint appears to have started to # file. As of lately, the endpoint appears to have started to
# respond with 500 "NullPointerException" on attempts to download a # respond with 500 "NullPointerException" on attempts to download a
# .fit file for an activity without one. # .fit file for an activity without one.
if response.status_code in [404, 500]: if response.status_code in [404, 500]:
# Manually entered activity, no file source available # Manually entered activity, no file source available
return (None,None) return None, None
if response.status_code != 200: if response.status_code != 200:
raise Exception( raise Exception(
u"failed to get original activity file for {}: {}\n{}".format( u"failed to get original activity file for {}: {}\n{}".format(
@ -334,13 +377,12 @@ class GarminClient(object):
# return the first entry from the zip archive where the filename is # return the first entry from the zip archive where the filename is
# activity_id (should be the only entry!) # activity_id (should be the only entry!)
zip = zipfile.ZipFile(BytesIO(response.content), mode="r") zip_file = zipfile.ZipFile(BytesIO(response.content), mode="r")
for path in zip.namelist(): for path in zip_file.namelist():
fn, ext = os.path.splitext(path) fn, ext = os.path.splitext(path)
if fn==str(activity_id): if fn.startswith(str(activity_id)):
return ext[1:], zip.open(path).read() return ext[1:], zip_file.open(path).read()
return (None,None) return None, None
def get_activity_fit(self, activity_id): def get_activity_fit(self, activity_id):
"""Return a FIT representation for a given activity. If the activity """Return a FIT representation for a given activity. If the activity
@ -360,21 +402,52 @@ class GarminClient(object):
# and cannot be exported to fit # 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 _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/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 @require_session
def upload_activity(self, file, format=None, name=None, description=None, activity_type=None, private=None): 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. """Upload a GPX, TCX, or FIT file for an activity.
:param file: Path or open file :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 :param name: Optional name for the activity on Garmin Connect
:type name: str
:param description: Optional description for the activity on Garmin Connect :param description: Optional description for the activity on Garmin Connect
:type description: str
:param activity_type: Optional activityType key (lowercase: e.g. running, cycling) :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. :param private: If true, then activity will be set as private.
:type private: bool
:returns: ID of the newly-uploaded activity :returns: ID of the newly-uploaded activity
:rtype: int :rtype: int
""" """
if isinstance(file, basestring): if isinstance(file, str):
file = open(file, "rb") file = open(file, "rb")
# guess file type if unspecified # guess file type if unspecified
@ -388,36 +461,61 @@ class GarminClient(object):
# upload it # upload it
files = dict(data=(fn, file)) files = dict(data=(fn, file))
response = self.session.post("https://connect.garmin.com/modern/proxy/upload-service/upload/.{}".format(format), response = self.session.post("https://connect.garmin.com/proxy/upload-service/upload/.{}".format(format),
files=files, headers={"nk": "NT"}) files=files, headers={"nk": "NT"})
# check response and get activity ID # check response and get activity ID
try: try:
j = response.json()["detailedImportResult"] j = response.json()["detailedImportResult"]
except (json.JSONDecodeException, KeyError): except (json.JSONDecodeError, KeyError):
raise Exception(u"failed to upload {} for activity: {}\n{}".format( raise Exception(u"failed to upload {} for activity: {}\n{}".format(
format, response.status_code, response.text)) format, response.status_code, response.text))
if len(j["failures"]) or len(j["successes"])<1: # single activity, immediate success
raise Exception(u"failed to upload {} for activity: {}\n{}".format( if len(j["successes"]) == 1 and len(j["failures"]) == 0:
format, response.status_code, j["failures"])) 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( raise Exception(u"uploading {} resulted in multiple activities ({})".format(
format, len(j["successes"]))) 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 # add optional fields
data = {} data = {}
if name is not None: data['activityName'] = name if name is not None:
if description is not None: data['description'] = name data['activityName'] = name
if activity_type is not None: data['activityTypeDTO'] = {"typeKey": activity_type} if description is not None:
if private: data['privacy'] = {"typeKey": "private"} data['description'] = description
if activity_type is not None:
data['activityTypeDTO'] = {"typeKey": activity_type}
if private:
data['privacy'] = {"typeKey": "private"}
if data: if data:
data['activityId'] = activity_id data['activityId'] = activity_id
encoding_headers = {"Content-Type": "application/json; charset=UTF-8"} # see Tapiriik 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) 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: if response.status_code != 204:
raise Exception(u"failed to set metadata for activity {}: {}\n{}".format( raise Exception(u"failed to set metadata for activity {}: {}\n{}".format(
activity_id, response.status_code, response.text)) activity_id, response.status_code, response.text))

View File

@ -0,0 +1,81 @@
#! /usr/bin/env python
import getpass
import logging
import os
from datetime import timedelta
from typing import Callable, List
import garminexport.backup
from garminexport.backup import supported_export_formats
from garminexport.garminclient import GarminClient
from garminexport.retryer import Retryer, ExponentialBackoffDelayStrategy, MaxRetriesStopStrategy
log = logging.getLogger(__name__)
def incremental_backup(username: str,
password: str = None,
user_agent_fn: Callable[[],str] = None,
backup_dir: str = os.path.join(".", "activities"),
export_formats: List[str] = None,
ignore_errors: bool = False,
max_retries: int = 7):
"""Performs (incremental) backups of activities for a given Garmin Connect account.
:param username: Garmin Connect user name
:param password: Garmin Connect user password. Default: None. If not provided, would be asked interactively.
:keyword user_agent_fn: A function that, when called, produces a
`User-Agent` string to be used as `User-Agent` for the remainder of the
session. If set to None, the default user agent of the http request
library is used.
:type user_agent_fn: Callable[[], str]
:param backup_dir: Destination directory for downloaded activities. Default: ./activities/".
:param export_formats: List of desired output formats (json_summary, json_details, gpx, tcx, fit).
Default: `None` which means all supported formats will be backed up.
:param ignore_errors: Ignore errors and keep going. Default: False.
:param max_retries: The maximum number of retries to make on failed attempts to fetch an activity.
Exponential backoff will be used, meaning that the delay between successive attempts
will double with every retry, starting at one second. Default: 7.
The activities are stored in a local directory on the user's computer.
The backups are incremental, meaning that only activities that aren't already
stored in the backup directory will be downloaded.
"""
# if no --format was specified, all formats are to be backed up
export_formats = export_formats if export_formats else supported_export_formats
log.info("backing up formats: %s", ", ".join(export_formats))
if not os.path.isdir(backup_dir):
os.makedirs(backup_dir)
if not password:
password = getpass.getpass("Enter password: ")
# set up a retryer that will handle retries of failed activity downloads
retryer = Retryer(
delay_strategy=ExponentialBackoffDelayStrategy(initial_delay=timedelta(seconds=1)),
stop_strategy=MaxRetriesStopStrategy(max_retries))
with GarminClient(username, password, user_agent_fn) as client:
# get all activity ids and timestamps from Garmin account
log.info("scanning activities for %s ...", username)
activities = set(retryer.call(client.list_activities))
log.info("account has a total of %d activities", len(activities))
missing_activities = garminexport.backup.need_backup(activities, backup_dir, export_formats)
backed_up = activities - missing_activities
log.info("%s contains %d backed up activities", backup_dir, len(backed_up))
log.info("activities that aren't backed up: %d", len(missing_activities))
for index, activity in enumerate(missing_activities):
id, start = activity
log.info("backing up activity %s from %s (%d out of %d) ...",
id, start, index + 1, len(missing_activities))
try:
garminexport.backup.download(client, activity, retryer, backup_dir, export_formats)
except Exception as e:
log.error("failed with exception: %s", e)
if not ignore_errors:
raise

View File

@ -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."""

View File

@ -1,15 +1,14 @@
import abc import abc
from datetime import datetime
from datetime import timedelta
import logging import logging
import time import time
from datetime import datetime
from datetime import timedelta
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
class GaveUpError(Exception): class GaveUpError(Exception):
"""Raised by a :class:`Retryer` that has exceeded its maximum number """Raised by a :class:`Retryer` that has exceeded its maximum number of retries."""
of retries."""
pass pass
@ -22,8 +21,7 @@ class DelayStrategy(object):
def next_delay(self, attempts): def next_delay(self, attempts):
"""Returns the time to wait before the next attempt. """Returns the time to wait before the next attempt.
:param attempts: The total number of (failed) attempts performed thus :param attempts: The total number of (failed) attempts performed thus far.
far.
:type attempts: int :type attempts: int
:return: The delay before the next attempt. :return: The delay before the next attempt.
@ -33,8 +31,8 @@ class DelayStrategy(object):
class FixedDelayStrategy(DelayStrategy): class FixedDelayStrategy(DelayStrategy):
"""A retry :class:`DelayStrategy` that produces a fixed delay between """A retry :class:`DelayStrategy` that produces a fixed delay between attempts."""
attempts."""
def __init__(self, delay): def __init__(self, delay):
""" """
:param delay: Attempt delay. :param delay: Attempt delay.
@ -56,7 +54,7 @@ class ExponentialBackoffDelayStrategy(DelayStrategy):
def __init__(self, initial_delay): def __init__(self, initial_delay):
""" """
:param initial_delay: Initial delay. :param initial_delay: Initial delay.
:type delay: `timedelta` :type initial_delay: `timedelta`
""" """
self.initial_delay = initial_delay self.initial_delay = initial_delay
@ -68,25 +66,21 @@ class ExponentialBackoffDelayStrategy(DelayStrategy):
class NoDelayStrategy(FixedDelayStrategy): class NoDelayStrategy(FixedDelayStrategy):
"""A retry :class:`DelayStrategy` that doesn't introduce any delay between """A retry :class:`DelayStrategy` that doesn't introduce any delay between attempts."""
attempts."""
def __init__(self): def __init__(self):
super(NoDelayStrategy, self).__init__(timedelta(seconds=0)) super(NoDelayStrategy, self).__init__(timedelta(seconds=0))
class ErrorStrategy(object): class ErrorStrategy(object):
"""Used by a :class:`Retryer` to determine which errors are to be """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 suppressed and which errors are to be re-raised and thereby end the (re)trying."""
(re)trying."""
__metaclass__ = abc.ABCMeta __metaclass__ = abc.ABCMeta
@abc.abstractmethod @abc.abstractmethod
def should_suppress(self, error): def should_suppress(self, error):
"""Called after an attempt that raised an exception to determine if """Called after an attempt that raised an exception to determine if
that error should be suppressed (continue retrying) or be re-raised that error should be suppressed (continue retrying) or be re-raised (and end the retrying).
(and end the retrying).
:param error: Error that was raised from an attempt. :param error: Error that was raised from an attempt.
""" """
@ -122,13 +116,14 @@ class StopStrategy(object):
class NeverStopStrategy(StopStrategy): class NeverStopStrategy(StopStrategy):
"""A :class:`StopStrategy` that never gives up.""" """A :class:`StopStrategy` that never gives up."""
def should_continue(self, attempts, elapsed_time): def should_continue(self, attempts, elapsed_time):
return True return True
class MaxRetriesStopStrategy(StopStrategy): class MaxRetriesStopStrategy(StopStrategy):
"""A :class:`StopStrategy` that gives up after a certain number of """A :class:`StopStrategy` that gives up after a certain number of retries."""
retries."""
def __init__(self, max_retries): def __init__(self, max_retries):
self.max_retries = 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 to decide if the error should be suppressed or re-raised (in which case
the retrying ends with that error). the retrying ends with that error).
""" """
def __init__( def __init__(
self, self,
returnval_predicate=lambda returnval: True, returnval_predicate=lambda returnval: True,
@ -172,7 +168,7 @@ class Retryer(object):
:param stop_strategy: determines when we are to stop retrying. :param stop_strategy: determines when we are to stop retrying.
:type stop_strategy: :class:`StopStrategy` :type stop_strategy: :class:`StopStrategy`
:param error_strategy: determines which errors (if any) to suppress :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` :type error_strategy: :class:`ErrorStrategy`
""" """
self.returnval_predicate = returnval_predicate self.returnval_predicate = returnval_predicate
@ -180,7 +176,6 @@ class Retryer(object):
self.stop_strategy = stop_strategy self.stop_strategy = stop_strategy
self.error_strategy = error_strategy self.error_strategy = error_strategy
def call(self, function, *args, **kw): def call(self, function, *args, **kw):
"""Calls the given `function`, with the given arguments, repeatedly """Calls the given `function`, with the given arguments, repeatedly
until either (1) a satisfactory result is obtained (as indicated by until either (1) a satisfactory result is obtained (as indicated by
@ -208,16 +203,13 @@ class Retryer(object):
return returnval return returnval
log.debug('{%s}: failed: return value: %s', name, returnval) log.debug('{%s}: failed: return value: %s', name, returnval)
except Exception as e: 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 raise e
log.debug('{%s}: failed: error: %s', name, str(e)) log.debug('{%s}: failed: error: %s', name, e)
elapsed_time = datetime.now() - start elapsed_time = datetime.now() - start
# should we make another attempt? # should we make another attempt?
if not self.stop_strategy.should_continue(attempts, elapsed_time): if not self.stop_strategy.should_continue(attempts, elapsed_time):
raise GaveUpError( raise GaveUpError('{{}}: gave up after {} failed attempt(s)'.format(name, attempts))
'{%s}: gave up after %d failed attempt(s)' %
(name, attempts))
delay = self.delay_strategy.next_delay(attempts) delay = self.delay_strategy.next_delay(attempts)
log.info('{%s}: waiting %d seconds for next attempt' % log.info('{%s}: waiting %d seconds for next attempt', name, delay.total_seconds())
(name, delay.total_seconds()))
time.sleep(delay.total_seconds()) time.sleep(delay.total_seconds())

View File

@ -1,90 +0,0 @@
#! /usr/bin/env python
"""A program that downloads one particular activity from a given Garmin
Connect account and stores it locally on the user's computer.
"""
import argparse
from datetime import timedelta
import getpass
from garminexport.garminclient import GarminClient
import garminexport.backup
from garminexport.retryer import (
Retryer, ExponentialBackoffDelayStrategy, MaxRetriesStopStrategy)
import json
import logging
import os
import sys
import traceback
import dateutil.parser
logging.basicConfig(
level=logging.INFO, format="%(asctime)-15s [%(levelname)s] %(message)s")
log = logging.getLogger(__name__)
LOG_LEVELS = {
"DEBUG": logging.DEBUG,
"INFO": logging.INFO,
"WARNING": logging.WARNING,
"ERROR": logging.ERROR
}
"""Command-line (string-based) log-level mapping to logging module levels."""
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description=("Downloads one particular activity for a given "
"Garmin Connect account."))
# positional args
parser.add_argument(
"username", metavar="<username>", type=str, help="Account user name.")
parser.add_argument(
"activity", metavar="<activity>", type=int, help="Activity ID.")
parser.add_argument(
"format", metavar="<format>", type=str,
help="Export format (one of: {}).".format(
garminexport.backup.export_formats))
# optional args
parser.add_argument(
"--password", type=str, help="Account password.")
parser.add_argument(
"--destination", metavar="DIR", type=str,
help=("Destination directory for downloaded activity. Default: "
"./activities/"), default=os.path.join(".", "activities"))
parser.add_argument(
"--log-level", metavar="LEVEL", type=str,
help=("Desired log output level (DEBUG, INFO, WARNING, ERROR). "
"Default: INFO."), default="INFO")
args = parser.parse_args()
if not args.log_level in LOG_LEVELS:
raise ValueError("Illegal log-level argument: {}".format(
args.log_level))
if not args.format in garminexport.backup.export_formats:
raise ValueError(
"Uncrecognized export format: '{}'. Must be one of {}".format(
args.format, garminexport.backup.export_formats))
logging.root.setLevel(LOG_LEVELS[args.log_level])
try:
if not os.path.isdir(args.destination):
os.makedirs(args.destination)
if not args.password:
args.password = getpass.getpass("Enter password: ")
with GarminClient(args.username, args.password) as client:
log.info("fetching activity {} ...".format(args.activity))
summary = client.get_activity_summary(args.activity)
# set up a retryer that will handle retries of failed activity
# downloads
retryer = Retryer(
delay_strategy=ExponentialBackoffDelayStrategy(
initial_delay=timedelta(seconds=1)),
stop_strategy=MaxRetriesStopStrategy(5))
starttime = dateutil.parser.parse(summary["summaryDTO"]["startTimeGMT"])
garminexport.backup.download(
client, (args.activity, starttime), retryer, args.destination, export_formats=[args.format])
except Exception as e:
exc_type, exc_value, exc_traceback = sys.exc_info()
log.error(u"failed with exception: %s", e)
raise

View File

@ -1,7 +0,0 @@
requests==2.21.0
python-dateutil==2.4.1
future==0.16.0
nose==1.3.7
coverage==4.2
mock==2.0.0

View File

@ -10,10 +10,9 @@ Garmin Connect.
import argparse import argparse
import getpass import getpass
from garminexport.garminclient import GarminClient
import json
import logging import logging
import sys
from garminexport.garminclient import GarminClient
logging.basicConfig(level=logging.INFO, format="%(asctime)-15s [%(levelname)s] %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)-15s [%(levelname)s] %(message)s")
log = logging.getLogger(__name__) log = logging.getLogger(__name__)

View File

@ -2,10 +2,10 @@
import argparse import argparse
import getpass import getpass
from garminexport.garminclient import GarminClient
import json import json
import logging import logging
import sys
from garminexport.garminclient import GarminClient
logging.basicConfig(level=logging.INFO, format="%(asctime)-15s [%(levelname)s] %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)-15s [%(levelname)s] %(message)s")
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -31,17 +31,17 @@ if __name__ == "__main__":
with GarminClient(args.username, args.password) as client: with GarminClient(args.username, args.password) as client:
log.info("activities:") log.info("activities:")
activity_ids = client.list_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) log.info(activity_ids)
latest_activity, latest_activity_start = activity_ids[0] latest_activity, latest_activity_start = activity_ids[0]
activity = client.get_activity_summary(latest_activity) activity = client.get_activity_summary(latest_activity)
log.info(u"activity id: %s", activity["activity"]["activityId"]) log.info("activity id: %s", activity["activity"]["activityId"])
log.info(u"activity name: '%s'", activity["activity"]["activityName"]) log.info("activity name: '%s'", activity["activity"]["activityName"])
log.info(u"activity description: '%s'", activity["activity"]["activityDescription"]) log.info("activity description: '%s'", activity["activity"]["activityDescription"])
log.info(json.dumps(client.get_activity_details(latest_activity), indent=4)) log.info(json.dumps(client.get_activity_details(latest_activity), indent=4))
log.info(client.get_activity_gpx(latest_activity)) log.info(client.get_activity_gpx(latest_activity))
except Exception as e: except Exception as e:
log.error(u"failed with exception: %s", e) log.error("failed with exception: %s", e)
finally: finally:
log.info("done") log.info("done")

View File

@ -1,26 +1,70 @@
#!/usr/bin/env python
"""Setup information for the Garmin Connect activity exporter.""" """Setup information for the Garmin Connect activity exporter."""
from setuptools import find_packages from setuptools import setup, Extension
from distutils.core import setup from os import path
# needed for Python 2.7 (ensures open() defaults to text mode with universal
# newlines, and accepts an argument to specify the text encoding.
from io import open
here = path.abspath(path.dirname(__file__))
with open(path.join(here, 'README.md'), encoding='utf-8') as f:
long_description = f.read()
requires = [
'requests>=2.0,<3',
'python-dateutil~=2.4',
]
test_requires = [
'nose~=1.3',
'coverage~=4.2',
'mock~=2.0',
]
setup(name='garminexport',
version='0.4.0',
description='Garmin Connect activity exporter and backup tool',
long_description=long_description,
long_description_content_type='text/markdown',
author='Peter Gardfjäll',
author_email='peter.gardfjall.work@gmail.com',
setup(name="Garmin Connect activity exporter",
version="1.0.0",
description=("A program that downloads all activities for a given Garmin Connect account and stores them locally on the user's computer."),
long_description=open('README.md').read(),
author="Peter Gardfjäll",
author_email="peter.gardfjall.work@gmail.com",
install_requires=open('requirements.txt').read(),
license=open('LICENSE').read(),
url="https://github.com/petergardfjall/garminexport",
packages=["garminexport"],
classifiers=[ classifiers=[
'Development Status :: 4 - Beta', 'Development Status :: 4 - Beta',
'Intended Audience :: Developers', 'Intended Audience :: Developers',
'Intended Audience :: End Users/Desktop' 'Intended Audience :: End Users/Desktop',
'Intended Audience :: Developers',
'Natural Language :: English', 'Natural Language :: English',
'License :: OSI Approved :: Apache Software License', 'License :: OSI Approved :: Apache Software License',
'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.5+', 'Programming Language :: Python :: 3.5',
]) 'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.7',
'Programming Language :: Python :: 3.8',
],
keywords='garmin export backup',
url='https://github.com/petergardfjall/garminexport',
license='Apache License 2.0',
project_urls={
'Source': 'https://github.com/petergardfjall/garminexport.git',
'Tracker': 'https://github.com/petergardfjall/garminexport/issues',
},
packages=[
'garminexport',
'garminexport.cli',
],
python_requires='>=3.5.*, <4',
install_requires=requires,
test_requires=test_requires,
entry_points={
'console_scripts': [
'garmin-backup = garminexport.cli.backup:main',
'garmin-get-activity = garminexport.cli.get_activity:main',
'garmin-upload-activity = garminexport.cli.upload_activity:main',
],
},
)