├── tests ├── response_examples │ ├── example_original_with_fit.zip │ ├── example_original_with_gpx.zip │ ├── summary_9766544337.json │ └── list_activities.json ├── test_cli.py ├── test_wellness.py ├── conftest.py ├── test_activities.py └── test_download.py ├── .github ├── dependabot.yml └── workflows │ └── test.yml ├── garpy ├── __init__.py ├── resources │ └── default_config.yaml ├── settings.py ├── wellness.py ├── cli.py ├── activity.py └── download.py ├── pyproject.toml ├── README.rst └── LICENSE /tests/response_examples/example_original_with_fit.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/felipeam86/garpy/HEAD/tests/response_examples/example_original_with_fit.zip -------------------------------------------------------------------------------- /tests/response_examples/example_original_with_gpx.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/felipeam86/garpy/HEAD/tests/response_examples/example_original_with_gpx.zip -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "04:00" 8 | open-pull-requests-limit: 10 9 | target-branch: develop 10 | -------------------------------------------------------------------------------- /tests/response_examples/summary_9766544337.json: -------------------------------------------------------------------------------- 1 | { 2 | "activityId": 9766544337, 3 | "activityName": "Morning ride", 4 | "activityTypeDTO": { 5 | "typeKey": "cycling" 6 | }, 7 | "timeZoneUnitDTO": { 8 | "unitKey": "Africa/Johannesburg" 9 | }, 10 | "summaryDTO": { 11 | "startTimeLocal": "2017-02-24T08:00:00.0" 12 | } 13 | } -------------------------------------------------------------------------------- /garpy/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.4.0" 2 | 3 | import logging 4 | import sys 5 | 6 | from .activity import Activities, Activity 7 | from .download import ActivitiesDownloader 8 | from .wellness import Wellness 9 | 10 | # Create logger 11 | logger = logging.getLogger(__name__) 12 | 13 | # Avoid duplicate handlers 14 | logger.handlers = [] 15 | 16 | # Create STDERR handler 17 | handler = logging.StreamHandler(sys.stderr) 18 | handler.setFormatter( 19 | logging.Formatter("%(asctime)s %(levelname)s - %(message)s", "%Y-%m-%d %H:%M:%S") 20 | ) 21 | handler.setLevel(logging.INFO) 22 | logger.addHandler(handler) 23 | 24 | # Prevent multiple logging if called from other packages 25 | logger.propagate = False 26 | logger.setLevel(logging.INFO) 27 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "garpy" 3 | version = "0.4.0" 4 | description = "Python client for downloading activities from Garmin Connect" 5 | authors = ["Felipe Aguirre Martinez "] 6 | license = "MIT" 7 | readme = "README.rst" 8 | homepage = "https://github.com/felipeam86/garpy" 9 | 10 | [tool.poetry.scripts] 11 | garpy = "garpy.cli:main" 12 | 13 | [tool.poetry.dependencies] 14 | python = "^3.7.9" 15 | requests = "^2.22" 16 | pendulum = "^2.0" 17 | PyYAML = ">=5.1,<7.0" 18 | click = ">=7,<9" 19 | tqdm = "^4.36" 20 | cloudscraper = "^1.2.58" 21 | garpyclient = "^0.1.1" 22 | 23 | [tool.poetry.dev-dependencies] 24 | pytest = "^7.2.0" 25 | pytest-cov = "^4.0" 26 | coveralls = "^3.0" 27 | black = "^22.12.0" 28 | isort = "^5.11.4" 29 | 30 | [build-system] 31 | requires = ["poetry>=0.12"] 32 | build-backend = "poetry.masonry.api" 33 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test package 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - develop 8 | pull_request: 9 | branches: 10 | - main 11 | - develop 12 | 13 | jobs: 14 | build: 15 | 16 | runs-on: ${{ matrix.os }} 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | python-version: ["3.7", "3.8", "3.9", "3.10"] 21 | os: [ubuntu-latest, macOS-latest, windows-latest] 22 | 23 | steps: 24 | - uses: actions/checkout@v2 25 | - name: Set up Python ${{ matrix.python-version }} 26 | uses: actions/setup-python@v2 27 | with: 28 | python-version: ${{ matrix.python-version }} 29 | - name: Install dependencies 30 | run: | 31 | python --version 32 | python -m pip install --upgrade pip 33 | python -m pip install --upgrade poetry coveralls 34 | poetry install 35 | - name: Test with pytest 36 | run: | 37 | poetry run pytest --cov-report term-missing --cov=garpy tests/ 38 | - name: Publish coverage to Coveralls 39 | env: 40 | COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} 41 | if: ${{ env.COVERALLS_REPO_TOKEN }} 42 | run: | 43 | coverage xml 44 | coveralls 45 | - name: Lint with black 46 | run: | 47 | poetry run black --diff --check . 48 | - name: Lint with isort 49 | run: | 50 | poetry run isort --check --profile black . 51 | -------------------------------------------------------------------------------- /garpy/resources/default_config.yaml: -------------------------------------------------------------------------------- 1 | backup-dir: 'activities' 2 | user-agent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.101 Safari/537.36" 3 | endpoints: 4 | SSO_LOGIN_URL: "https://sso.garmin.com/sso/signin" 5 | ACTIVITY_LIST: "https://connect.garmin.com/proxy/activitylist-service/activities/search/activities" 6 | activities: 7 | summary: 8 | endpoint: "https://connect.garmin.com/proxy/activity-service/activity/{id}" 9 | suffix: "_summary.json" 10 | details: 11 | endpoint: "https://connect.garmin.com/proxy/activity-service/activity/{id}/details" 12 | suffix: "_details.json" 13 | gpx: 14 | endpoint: "https://connect.garmin.com/proxy/download-service/export/gpx/activity/{id}" 15 | suffix: ".gpx" 16 | tolerate: 17 | - 404 18 | - 204 19 | tcx: 20 | endpoint: "https://connect.garmin.com/proxy/download-service/export/tcx/activity/{id}" 21 | suffix: ".tcx" 22 | tolerate: 23 | - 404 24 | original: 25 | endpoint: "https://connect.garmin.com/proxy/download-service/files/activity/{id}" 26 | suffix: ".fit" 27 | tolerate: 28 | - 404 29 | - 500 30 | kml: 31 | endpoint: "https://connect.garmin.com/proxy/download-service/export/kml/activity/{id}" 32 | suffix: ".kml" 33 | tolerate: 34 | - 404 35 | - 204 36 | wellness: 37 | endpoint: "https://connect.garmin.com/proxy/download-service/files/wellness/{date}" 38 | tolerate: 39 | - 404 40 | -------------------------------------------------------------------------------- /garpy/settings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import logging 4 | from pathlib import Path 5 | 6 | import yaml 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | def recursive_update(original_dict: dict, new_dict: dict) -> dict: 12 | """Recursively update original_dict with new_dict""" 13 | for new_key, new_value in new_dict.items(): 14 | if isinstance(new_value, dict): 15 | original_dict[new_key] = recursive_update( 16 | original_dict.get(new_key, {}), new_value 17 | ) 18 | else: 19 | original_dict[new_key] = new_value 20 | return original_dict 21 | 22 | 23 | class Password: 24 | def __init__(self, password: str) -> None: 25 | self.password = password or "" 26 | 27 | def __repr__(self) -> str: 28 | return "*" * len(self.password) 29 | 30 | def get(self) -> str: 31 | return self.password 32 | 33 | def __bool__(self): 34 | return bool(self.password) 35 | 36 | 37 | # Configurations are loaded from the defaults of the package and eventually a local config.yaml file 38 | config_files = [ 39 | Path(__file__).parent / "resources" / "default_config.yaml", 40 | Path("config.yaml"), 41 | ] 42 | 43 | config = {} 44 | for config_file in config_files: 45 | if config_file.exists(): 46 | new_config = yaml.safe_load(config_file.read_text()) 47 | if isinstance(new_config, dict): 48 | config = recursive_update(config, new_config) 49 | 50 | 51 | config["backup-dir"] = Path(config["backup-dir"]).absolute() 52 | config["password"] = Password(config.get("password")) 53 | -------------------------------------------------------------------------------- /garpy/wellness.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | 3 | from dataclasses import dataclass 4 | from pathlib import Path 5 | 6 | import pendulum 7 | from garpyclient import GarminClient 8 | 9 | 10 | @dataclass 11 | class Wellness: 12 | """Garmin wellness data 13 | 14 | 15 | Parameters 16 | ---------- 17 | date 18 | Date for which you want to fetch wellness data 19 | 20 | """ 21 | 22 | date: pendulum.DateTime 23 | 24 | def __post_init__(self): 25 | if self.date.in_tz("local") > pendulum.DateTime.today().in_tz("local"): 26 | raise ValueError( 27 | f"garpy cannot download data from the future... " 28 | f"try a date before today {self.date.format('YYYY-MM-DD')}" 29 | ) 30 | 31 | @property 32 | def base_filename(self) -> str: 33 | return self.date.format("YYYY-MM-DD") + ".zip" 34 | 35 | def get_export_filepath(self, backup_dir: Path) -> Path: 36 | return Path(backup_dir) / self.base_filename 37 | 38 | def download(self, client: GarminClient, backup_dir: Path) -> None: 39 | """Download activity on the given format to the given backup directory 40 | 41 | Parameters 42 | ---------- 43 | client 44 | Authenticated GarminClient 45 | backup_dir 46 | Where to download the file 47 | """ 48 | response = client.get_wellness(self.date) 49 | backup_dir = Path(backup_dir) 50 | backup_dir.mkdir(exist_ok=True) 51 | filepath = self.get_export_filepath(backup_dir) 52 | if response.status_code == 200: 53 | filepath.write_bytes(response.content) 54 | else: 55 | with open(str(Path(backup_dir) / ".not_found"), mode="a") as not_found: 56 | not_found.write(str(filepath.name) + "\n") 57 | -------------------------------------------------------------------------------- /tests/response_examples/list_activities.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "activityId": 2532452238, 4 | "activityName": "Random walking", 5 | "activityType": { 6 | "typeKey": "walking" 7 | }, 8 | "startTimeGMT": "2018-11-24 09:30:00" 9 | }, 10 | { 11 | "activityId": 3454373317, 12 | "activityName": "Random strength_training", 13 | "activityType": { 14 | "typeKey": "strength_training" 15 | }, 16 | "startTimeGMT": "2018-11-22 09:50:00" 17 | }, 18 | { 19 | "activityId": 5635475745, 20 | "activityName": "Random indoor_cardio", 21 | "activityType": { 22 | "typeKey": "indoor_cardio" 23 | }, 24 | "startTimeGMT": "2018-10-20 19:40:10" 25 | }, 26 | { 27 | "activityId": 2646435676, 28 | "activityName": "Random walking", 29 | "activityType": { 30 | "typeKey": "walking" 31 | }, 32 | "startTimeGMT": "2018-08-12 17:43:00" 33 | }, 34 | { 35 | "activityId": 7563564363, 36 | "activityName": "Random walking", 37 | "activityType": { 38 | "typeKey": "walking" 39 | }, 40 | "startTimeGMT": "2018-07-23 07:36:00" 41 | }, 42 | { 43 | "activityId": 8768456369, 44 | "activityName": "Random running", 45 | "activityType": { 46 | "typeKey": "running" 47 | }, 48 | "startTimeGMT": "2018-06-22 07:50:39" 49 | }, 50 | { 51 | "activityId": 8573636543, 52 | "activityName": "Random indoor_cardio", 53 | "activityType": { 54 | "typeKey": "indoor_cardio" 55 | }, 56 | "startTimeGMT": "2017-06-28 08:01:10" 57 | }, 58 | { 59 | "activityId": 4754684652, 60 | "activityName": "Random running", 61 | "activityType": { 62 | "typeKey": "running" 63 | }, 64 | "startTimeGMT": "2017-05-28 09:00:00" 65 | }, 66 | { 67 | "activityId": 8967453672, 68 | "activityName": "Random strength_training", 69 | "activityType": { 70 | "typeKey": "strength_training" 71 | }, 72 | "startTimeGMT": "2017-04-23 20:42:00" 73 | }, 74 | { 75 | "activityId": 9545734527, 76 | "activityName": "Random rock_climbing", 77 | "activityType": { 78 | "typeKey": "rock_climbing" 79 | }, 80 | "startTimeGMT": "2017-03-20 11:00:56" 81 | } 82 | ] -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from pathlib import Path 5 | from unittest.mock import patch 6 | 7 | from click.testing import CliRunner 8 | 9 | from garpy import cli 10 | 11 | 12 | class TestCLI: 13 | """cli.main""" 14 | 15 | def test_download_username_password_only(self): 16 | with patch.object(cli.GarminClient, "_authenticate", return_value=None): 17 | with patch.object(cli.ActivitiesDownloader, "__call__", return_value=None): 18 | runner = CliRunner() 19 | with runner.isolated_filesystem(): 20 | result = runner.invoke( 21 | cli.main, ["download", "-u", "dummy", "-p", "password"] 22 | ) 23 | assert result.exit_code == 0 24 | 25 | def test_download_several_formats(self): 26 | with patch.object(cli.GarminClient, "_authenticate", return_value=None): 27 | with patch.object(cli.ActivitiesDownloader, "__call__", return_value=None): 28 | runner = CliRunner() 29 | with runner.isolated_filesystem(): 30 | result = runner.invoke( 31 | cli.main, 32 | [ 33 | "download", 34 | "-u", 35 | "dummy", 36 | "-p", 37 | "password", 38 | "-f", 39 | "gpx", 40 | "-f", 41 | "fit", 42 | ], 43 | ) 44 | assert result.exit_code == 0 45 | 46 | def test_download_fails_with_existing_file_as_bakcup_dir(self, tmp_path): 47 | with patch.object(cli.GarminClient, "_authenticate", return_value=None): 48 | with patch.object(cli.ActivitiesDownloader, "__call__", return_value=None): 49 | runner = CliRunner() 50 | with runner.isolated_filesystem(): 51 | backup_dir = Path(tmp_path) / "text_file" 52 | backup_dir.touch() 53 | result = runner.invoke( 54 | cli.main, 55 | ["download", "-u", "dummy", "-p", "password", str(backup_dir)], 56 | ) 57 | assert result.exit_code == 1 58 | assert ( 59 | str(result.exception) 60 | == "The provided backup directory exists and is a file" 61 | ) 62 | -------------------------------------------------------------------------------- /tests/test_wellness.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from pathlib import Path 5 | from unittest.mock import Mock 6 | 7 | import pendulum 8 | import pytest 9 | from conftest import get_mocked_response 10 | 11 | from garpy import Wellness 12 | 13 | RESPONSE_EXAMPLES_PATH = Path(__file__).parent / "response_examples" 14 | 15 | 16 | class TestActivity: 17 | """activities.Activity""" 18 | 19 | def test_instantiation(self, tmp_path): 20 | date = pendulum.DateTime(2019, 9, 27) 21 | wellness = Wellness(date) 22 | assert wellness.base_filename == "2019-09-27.zip" 23 | assert wellness.get_export_filepath(tmp_path) == tmp_path / "2019-09-27.zip" 24 | 25 | def test_wont_travel_to_future(self): 26 | date = pendulum.DateTime.today().add(days=1) 27 | with pytest.raises(ValueError) as excinfo: 28 | _ = Wellness(date) 29 | 30 | assert ( 31 | f"garpy cannot download data from the future... " 32 | f"try a date before today {date.format('YYYY-MM-DD')}" in str(excinfo.value) 33 | ) 34 | 35 | def test_download(self, client_wellness, tmp_path): 36 | date = pendulum.DateTime(2019, 9, 27) 37 | wellness = Wellness(date) 38 | with client_wellness: 39 | wellness.download(client_wellness, tmp_path) 40 | client_wellness.get_wellness.assert_called_once() 41 | client_wellness.get_wellness.assert_called_with(date) 42 | expected_downloaded_file_path = wellness.get_export_filepath(tmp_path) 43 | assert expected_downloaded_file_path.exists() 44 | assert not (Path(tmp_path) / ".not_found").exists() 45 | zipped_file = RESPONSE_EXAMPLES_PATH / "example_original_with_fit.zip" 46 | assert ( 47 | expected_downloaded_file_path.read_bytes() == zipped_file.read_bytes() 48 | ) 49 | 50 | def test_download_inexistent_day(self, client, tmp_path): 51 | date = pendulum.DateTime(2019, 9, 27) 52 | wellness = Wellness(date) 53 | with client: 54 | client.session.get = Mock( 55 | return_value=get_mocked_response(404), func_name="client.session.get()" 56 | ) 57 | wellness.download(client, tmp_path) 58 | expected_downloaded_file_path = wellness.get_export_filepath(tmp_path) 59 | assert not expected_downloaded_file_path.exists() 60 | assert (Path(tmp_path) / ".not_found").exists() 61 | assert ( 62 | expected_downloaded_file_path.name 63 | in (Path(tmp_path) / ".not_found").read_text() 64 | ) 65 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | from unittest.mock import Mock 4 | 5 | import pytest 6 | from garpyclient import GarminClient 7 | 8 | from garpy import Activity 9 | 10 | RESPONSE_EXAMPLES_PATH = Path(__file__).parent / "response_examples" 11 | 12 | 13 | @pytest.fixture 14 | def activity(): 15 | activities = json.loads( 16 | (RESPONSE_EXAMPLES_PATH / "list_activities.json").read_text() 17 | ) 18 | return Activity.from_garmin_activity_list_entry(activities[0]) 19 | 20 | 21 | @pytest.fixture 22 | def client(): 23 | clg = GarminClient(username="dummy", password="dummy") 24 | clg._authenticate = Mock(return_value=None, name="clg._authenticate()") 25 | return clg 26 | 27 | 28 | @pytest.fixture 29 | def client_activities(client): 30 | activities = json.loads( 31 | (RESPONSE_EXAMPLES_PATH / "list_activities.json").read_text() 32 | ) 33 | assert len(activities) == 10 34 | client.list_activities = Mock(return_value=activities) 35 | client.get_activity = Mock(side_effect=get_activity) 36 | return client 37 | 38 | 39 | @pytest.fixture 40 | def client_wellness(client): 41 | client.get_wellness = Mock( 42 | return_value=get_mocked_response( 43 | status_code=200, 44 | content=( 45 | RESPONSE_EXAMPLES_PATH / "example_original_with_fit.zip" 46 | ).read_bytes(), 47 | ) 48 | ) 49 | return client 50 | 51 | 52 | def get_mocked_response(status_code, text=None, content=None): 53 | failed_response = Mock() 54 | failed_response.status_code = status_code 55 | failed_response.text = text or "" 56 | failed_response.content = content or b"" 57 | return failed_response 58 | 59 | 60 | def get_mocked_request(status_code=200, func_name=None, text=None): 61 | return Mock(return_value=get_mocked_response(status_code, text), name=func_name) 62 | 63 | 64 | def get_activity(activity_id, fmt): 65 | if fmt == "original": 66 | if activity_id == 3454373317: 67 | content = ( 68 | RESPONSE_EXAMPLES_PATH / "example_original_with_gpx.zip" 69 | ).read_bytes() 70 | else: 71 | content = ( 72 | RESPONSE_EXAMPLES_PATH / "example_original_with_fit.zip" 73 | ).read_bytes() 74 | return get_mocked_response(status_code=200, content=content) 75 | else: 76 | if (fmt == "summary") and (activity_id == 9766544337): 77 | text = (RESPONSE_EXAMPLES_PATH / "summary_9766544337.json").read_text() 78 | else: 79 | text = f"Trust me, this is a {fmt!r} file for activity {activity_id!r}" 80 | 81 | return get_mocked_response(status_code=200, text=text) 82 | -------------------------------------------------------------------------------- /garpy/cli.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import click 4 | from garpyclient import GarminClient 5 | 6 | from garpy import ActivitiesDownloader 7 | from garpy.settings import config 8 | 9 | FORMATS = set(config.get("activities").keys()) | {"fit"} 10 | 11 | 12 | @click.group() 13 | @click.version_option() 14 | def main(): 15 | pass 16 | 17 | 18 | @main.command() 19 | @click.argument("backup-dir", default=config.get("backup-dir")) 20 | @click.option( 21 | "--formats", 22 | "-f", 23 | multiple=True, 24 | help="Which formats to download. The flag can be used several times, e.g. '-f original -f gpx'", 25 | type=click.Choice(FORMATS), 26 | show_choices=True, 27 | default=FORMATS, 28 | ) 29 | @click.option( 30 | "--username", 31 | "-u", 32 | prompt=True, 33 | default=config.get("username"), 34 | metavar="{username}", 35 | help="Username of your Garmin account", 36 | ) 37 | @click.option( 38 | "--password", 39 | "-p", 40 | prompt=True, 41 | default=config.get("password").get(), 42 | metavar="{password}", 43 | help="Password of your Garmin account", 44 | hide_input=True, 45 | ) 46 | @click.option( 47 | "--activity", 48 | "-a", 49 | "activity_id", 50 | default=None, 51 | metavar="{ID}", 52 | help="Activity ID. If indicated, download only that activity, even if it has already been downloaded." 53 | " Otherwise, do incremental update of backup", 54 | hide_input=True, 55 | ) 56 | @click.option( 57 | "--user-agent", 58 | "user_agent", 59 | default=config["user-agent"], 60 | metavar="{user_agent}", 61 | help="User agent to be used by requests", 62 | hide_input=True, 63 | ) 64 | def download(backup_dir, formats, username, password, activity_id, user_agent): 65 | """Download activities from Garmin Connect 66 | 67 | Entry point for downloading activities from Garmin Connect. By default, it downloads all 68 | newly created activities since the last time you did a backup. 69 | 70 | If you specify an activity ID with the "-a/--activity" flag, only that activity will be downloaded, 71 | even if it has already been downloaded before. 72 | 73 | If no format is specified, the app will download all possible formats. Otherwise you can specify the 74 | formats you wish to download with the "-f/--formats" flag. The flag can be used several times if you 75 | wish to specify several formats, e.g., 'garpy download [OPTIONS] -f original -f gpx [BACKUP_DIR]' will 76 | download .fit and .gpx files 77 | """ 78 | if "fit" in formats: 79 | formats = set(formats) 80 | formats.remove("fit") 81 | formats.add("original") 82 | formats = tuple(formats) 83 | 84 | backup_dir = Path(backup_dir).absolute() 85 | if backup_dir.is_file(): 86 | raise Exception("The provided backup directory exists and is a file") 87 | 88 | with GarminClient( 89 | username=username, password=password, user_agent=user_agent 90 | ) as client: 91 | downloader = ActivitiesDownloader(client=client, backup_dir=backup_dir) 92 | downloader(formats=formats, activity_id=activity_id) 93 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ################################### 2 | Garpy: Make your garmin data yours! 3 | ################################### 4 | 5 | |PyPI-Versions| |PyPI-Status| |Codacy-Grade| |Tests| |Coveralls| 6 | 7 | ``garpy`` is a simple app used to backup your data from Garmin Connect. It can be used to do incremental 8 | backups of your data from Garmin Connect or to download one specific activity. 9 | 10 | ******************************** 11 | Incremental backup of activities 12 | ******************************** 13 | 14 | The first time you use it, all the activities found on your Garmin Connect account will be downloaded to 15 | the directory that you specify. Afterwards, each time you run the command, only the newly available 16 | activities will be downloaded. 17 | 18 | The command is used as follows: 19 | 20 | .. code:: sh 21 | 22 | garpy download {backup-dir} 23 | 24 | Behind the scenes, this is what will happen: 25 | 26 | - `garpy` will prompt you for your password and will then authenticate against Garmin Connect. 27 | - It will first fetch the list of all your activities from garmin. 28 | - It will check which activities have already been backed up on the given `backup-dir` 29 | - It will proceed to download all the missing activities. 30 | 31 | ************************************ 32 | Downloading one activity from its ID 33 | ************************************ 34 | 35 | If you wish to download only one activity or simple you want to refresh an already downloaded activity, 36 | use the '-a/--activity' flag as follows: 37 | 38 | .. code:: sh 39 | 40 | garpy download --activity 1674567326 {backup-dir} 41 | 42 | This will download the activity in all existing formats to the given `backup_dir` 43 | 44 | **************** 45 | Full CLI options 46 | **************** 47 | 48 | For more detailed usage, invoke the '--help' command: 49 | 50 | .. code:: sh 51 | 52 | $ garpy download --help 53 | Usage: garpy download [OPTIONS] [BACKUP_DIR] 54 | 55 | Download activities from Garmin Connect 56 | 57 | Entry point for downloading activities from Garmin Connect. By default, it 58 | downloads all newly created activities since the last time you did a backup. 59 | 60 | If you specify an activity ID with the "-a/--activity" flag, only that 61 | activity will be downloaded, even if it has already been downloaded before. 62 | 63 | If no format is specified, the app will download all possible formats. 64 | Otherwise you can specify the formats you wish to download with the 65 | "-f/--formats" flag. The flag can be used several times if you wish to 66 | specify several formats, e.g., 'garpy download [OPTIONS] -f original -f gpx 67 | [BACKUP_DIR]' will download .fit and .gpx files 68 | 69 | Options: 70 | -f, --formats [original|gpx|fit|tcx|kml|summary|details] 71 | Which formats to download. The flag can be 72 | used several times, e.g. '-f original -f 73 | gpx' 74 | -u, --username {username} Username of your Garmin account 75 | -p, --password {password} Password of your Garmin account 76 | -a, --activity {ID} Activity ID. If indicated, download only 77 | that activity, even if it has already been 78 | downloaded. Otherwise, do incremental update 79 | of backup 80 | --user-agent {user_agent} User agent to be used by requests 81 | --help Show this message and exit. 82 | 83 | 84 | ************ 85 | Installation 86 | ************ 87 | ``garpy`` requires Python 3.7 or higher on your system. For those who know your way around with Python, install 88 | ``garpy`` with pip as follows: 89 | 90 | .. code:: sh 91 | 92 | pip install -U garpy 93 | 94 | 95 | If you are new to Python or have Python 2 installed on your 96 | computer, I recommend you install Miniconda_. To my knowledge, it is the simplest way of installing a robust and 97 | lightweight Python environment. 98 | 99 | 100 | **************** 101 | Acknowledgements 102 | **************** 103 | 104 | The library is based on garminexport_. I borrowed the GarminClient, refactored it to my taste and 105 | created a package from it. 106 | 107 | 108 | .. |PyPI-Versions| image:: https://img.shields.io/pypi/pyversions/garpy.svg?logo=python&logoColor=white 109 | :target: https://pypi.org/project/garpy 110 | .. |PyPI-Status| image:: https://img.shields.io/pypi/v/garpy.svg 111 | :target: https://pypi.org/project/garpy 112 | .. |Codacy-Grade| image:: https://api.codacy.com/project/badge/Grade/2fbbd268e0a04cd0983291227be53873 113 | :target: https://app.codacy.com/manual/garpy/garpy/dashboard 114 | .. |Tests| image:: https://github.com/felipeam86/garpy/actions/workflows/test.yml/badge.svg 115 | :target: https://github.com/felipeam86/garpy/actions/workflows/test.yml 116 | .. |Coveralls| image:: https://coveralls.io/repos/github/felipeam86/garpy/badge.svg?branch=develop 117 | :target: https://coveralls.io/github/felipeam86/garpy?branch=develop 118 | 119 | 120 | .. _Miniconda: https://docs.conda.io/en/latest/miniconda.html 121 | .. _garminexport: https://github.com/petergardfjall/garminexport 122 | -------------------------------------------------------------------------------- /garpy/activity.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | 3 | import json 4 | import os 5 | import zipfile 6 | from dataclasses import dataclass 7 | from io import BytesIO 8 | from pathlib import Path 9 | from typing import Any, Dict 10 | 11 | import pendulum 12 | from garpyclient import GarminClient 13 | 14 | from .settings import config 15 | 16 | 17 | @dataclass(frozen=True, eq=True) 18 | class Activity: 19 | """Garmin activity 20 | 21 | Parameters 22 | ---------- 23 | id 24 | Activity ID on Garmin Connect 25 | name 26 | Name of the activity 27 | type 28 | Type of activity, e.g, Cycling, Swimming, etc 29 | start 30 | Start date and time 31 | 32 | Examples 33 | -------- 34 | .. code-block:: python 35 | 36 | >>> with GarminClient("my.sample@sample.com", "secretpassword") as client: 37 | >>> activity = Activity.from_garmin_connect(3983141717, client) 38 | 39 | """ 40 | 41 | id: int 42 | name: str 43 | type: str 44 | start: pendulum.DateTime 45 | 46 | @property 47 | def base_filename(self): 48 | filename = f"{self.start.in_tz('UTC').isoformat()}_{self.id}" 49 | if os.name == "nt": # Windows complains about : in filenames. 50 | filename = filename.replace(":", ".") 51 | return filename 52 | 53 | def get_export_filepath(self, backup_dir: Path, fmt: str) -> Path: 54 | format_parameters = config["activities"].get(fmt) 55 | if not format_parameters: 56 | raise ValueError(f"Format '{fmt}' unknown.") 57 | return Path(backup_dir) / (self.base_filename + format_parameters["suffix"]) 58 | 59 | @classmethod 60 | def from_garmin_summary(cls, summary: Dict[str, Any]): 61 | """Constructor based on garmin connect summary. 62 | 63 | Parameters 64 | ---------- 65 | summary 66 | JSON string representation of summary information fetched from garmin connect 67 | """ 68 | 69 | return cls( 70 | id=summary["activityId"], 71 | name=summary["activityName"], 72 | type=summary["activityTypeDTO"]["typeKey"], 73 | start=pendulum.parse( 74 | summary["summaryDTO"]["startTimeLocal"], 75 | tz=summary["timeZoneUnitDTO"]["unitKey"], 76 | ), 77 | ) 78 | 79 | @classmethod 80 | def from_garmin_activity_list_entry(cls, entry: Dict[str, Any]): 81 | """Constructor based on an entry from the list of activities from garmin connect. 82 | 83 | Parameters 84 | ---------- 85 | entry 86 | JSON string representation of an entry of an activity list fetched from garmin connect 87 | """ 88 | 89 | return cls( 90 | id=entry["activityId"], 91 | name=entry["activityName"], 92 | type=entry["activityType"]["typeKey"], 93 | # Unfortunately, Garmin connect does not provide timezone information on entries from list of activities 94 | start=pendulum.parse(entry["startTimeGMT"]), 95 | ) 96 | 97 | @classmethod 98 | def from_garmin_connect(cls, activity_id: int, client: GarminClient): 99 | """Constructor that fetches activity summary from Garmin Connect 100 | 101 | Parameters 102 | ---------- 103 | activity_id 104 | Activity ID on Garmin Connect 105 | client 106 | Authenticated GarminClient 107 | """ 108 | response = client.get_activity(activity_id, "summary") 109 | activity_summary = json.loads(response.text) 110 | activity = Activity.from_garmin_summary(activity_summary) 111 | 112 | return activity 113 | 114 | def download(self, client: GarminClient, fmt: str, backup_dir: Path) -> None: 115 | """Download activity on the given format to the given backup directory 116 | 117 | Parameters 118 | ---------- 119 | client 120 | Authenticated GarminClient 121 | fmt 122 | Format you wish to download 123 | backup_dir 124 | Where to download the file 125 | """ 126 | response = client.get_activity(self.id, fmt) 127 | filepath = self.get_export_filepath(backup_dir, fmt) 128 | backup_dir = Path(backup_dir) 129 | backup_dir.mkdir(exist_ok=True) 130 | if response.status_code == 200: 131 | if fmt == "original": 132 | zip_content = zipfile.ZipFile(BytesIO(response.content), mode="r") 133 | original_file_name = zip_content.namelist()[0] 134 | fit_bytes = zip_content.open(original_file_name).read() 135 | # Change file extension to the one on the zipped file 136 | filepath = filepath.with_suffix(Path(original_file_name).suffix) 137 | filepath.write_bytes(fit_bytes) 138 | 139 | # If original format is not FIT, register it as not found 140 | if filepath.suffix != ".fit": 141 | with open( 142 | str(Path(backup_dir) / ".not_found"), mode="a" 143 | ) as not_found: 144 | not_found.write( 145 | str(self.get_export_filepath(backup_dir, fmt).name) + "\n" 146 | ) 147 | else: 148 | filepath.write_text(response.text) 149 | else: 150 | with open(str(Path(backup_dir) / ".not_found"), mode="a") as not_found: 151 | not_found.write(str(filepath.name) + "\n") 152 | 153 | 154 | class Activities(list): 155 | @classmethod 156 | def list(cls, client: GarminClient): 157 | return cls( 158 | Activity.from_garmin_activity_list_entry(activity) 159 | for activity in client.list_activities() 160 | ) 161 | -------------------------------------------------------------------------------- /garpy/download.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | 3 | import logging 4 | from dataclasses import dataclass, field 5 | from pathlib import Path 6 | from typing import Dict 7 | 8 | from garpyclient import GarminClient 9 | 10 | from . import Activities, Activity 11 | from .settings import config 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | DEFAULT_FORMATS = tuple(config["activities"].keys()) 16 | 17 | 18 | @dataclass 19 | class ActivitiesDownloader: 20 | """Class for doing incremental backups of your Garmin activitiess 21 | 22 | Parameters 23 | ---------- 24 | client 25 | Authenticated GarminClient 26 | backup_dir 27 | Where to download the file 28 | """ 29 | 30 | client: GarminClient 31 | backup_dir: Path = field(default=config["backup-dir"]) 32 | 33 | def __post_init__(self): 34 | """Make sure that self.backup_dir is cast into Path and that it exits""" 35 | if isinstance(self.backup_dir, str): 36 | self.backup_dir = Path(self.backup_dir) 37 | self.backup_dir = self.backup_dir.absolute() 38 | self.backup_dir.mkdir(exist_ok=True) 39 | 40 | @property 41 | def existing_files(self): 42 | """Set of existing files on the backup directory""" 43 | return set(filepath for filepath in self.backup_dir.glob("*")) 44 | 45 | @property 46 | def not_found(self): 47 | """Set of files not found on a previous backup try""" 48 | if (self.backup_dir / ".not_found").exists(): 49 | return set( 50 | self.backup_dir / path.strip() 51 | for path in (self.backup_dir / ".not_found").read_text().split("\n") 52 | if path 53 | ) 54 | else: 55 | return set() 56 | 57 | def _discover_formats_to_download( 58 | self, activities: Activities, formats: tuple = DEFAULT_FORMATS 59 | ) -> Dict[Activity, tuple]: 60 | """Fetch list of activities and find which of them need backup and in what formats""" 61 | 62 | files_not_to_download = self.existing_files | self.not_found 63 | to_download = {} 64 | for activity in activities: 65 | needed_formats = tuple( 66 | fmt 67 | for fmt in formats 68 | if activity.get_export_filepath(self.backup_dir, fmt) 69 | not in files_not_to_download 70 | ) 71 | if needed_formats: 72 | to_download[activity] = needed_formats 73 | 74 | return to_download 75 | 76 | def download_all(self, activities: Activities, formats: tuple = DEFAULT_FORMATS): 77 | """Do an incremental backup of the specified formats. 78 | 79 | Parameters 80 | ---------- 81 | activities 82 | List of activities fetched from Garmin Connect 83 | formats 84 | Formats you wish to download 85 | """ 86 | to_download = self._discover_formats_to_download(activities, formats) 87 | if not to_download: 88 | logger.info("Backup folder up to date. No activities will be downloaded") 89 | return 90 | 91 | n_activities = len(to_download) 92 | logger.info(f"{n_activities} activities to be downloaded") 93 | 94 | to_download = progressbar(to_download.items()) 95 | for i, (activity, formats) in enumerate(to_download): 96 | to_download.desc = ( 97 | f"Downloading {activity.type!r} activity {activity.id!r} " 98 | f"from {activity.start.format('YYYY-MM-DD')}. Formats: {formats!r}" 99 | ) 100 | to_download.display() 101 | formats = progressbar(formats, leave=(i + 1 == n_activities)) 102 | for fmt in formats: 103 | formats.desc = f"Downloading format {fmt!r}" 104 | formats.display() 105 | activity.download(self.client, fmt, self.backup_dir) 106 | 107 | def download_one(self, activity: Activity, formats: tuple = DEFAULT_FORMATS): 108 | """Download specified formats for a given activity 109 | 110 | Parameters 111 | ---------- 112 | activity 113 | Activity you wish to download 114 | formats 115 | Formats you wish to download 116 | """ 117 | logger.info( 118 | f"Downloading {activity.type!r} activity {activity.id!r} " 119 | f"from {activity.start.format('YYYY-MM-DD')}." 120 | ) 121 | formats = progressbar(formats, leave=True) 122 | for fmt in formats: 123 | formats.desc = f"Downloading format {fmt!r}" 124 | formats.display() 125 | activity.download(self.client, fmt, self.backup_dir) 126 | 127 | def __call__(self, formats: tuple = DEFAULT_FORMATS, activity_id: int = None): 128 | logger.info( 129 | f"Downloading the following formats: {formats!r} " 130 | f"to this folder: {self.backup_dir}" 131 | ) 132 | if activity_id is None: 133 | logger.info("Querying list of activities") 134 | activities = Activities.list(self.client) 135 | logger.info( 136 | f"{len(activities)} activities in total found on Garmin Connect" 137 | ) 138 | self.download_all(activities=activities, formats=formats) 139 | else: 140 | logger.info(f"Fetching summary information for activity: {activity_id!r}") 141 | activity = Activity.from_garmin_connect(activity_id, self.client) 142 | self.download_one(activity=activity, formats=formats) 143 | 144 | 145 | def _isnotebook(): # pragma: no cover 146 | """Check if garpy is being run inside a Jupyter notebook""" 147 | try: 148 | shell = get_ipython().__class__.__name__ 149 | return shell == "ZMQInteractiveShell" 150 | except NameError: 151 | return False # Probably standard Python interpreter or IPython 152 | 153 | 154 | def progressbar(*args, **kwargs): # pragma: no cover 155 | """Make a progress bar depending on the environment garpy is running""" 156 | if _isnotebook(): 157 | from tqdm import tqdm_notebook as tqdm 158 | else: 159 | from tqdm import tqdm 160 | 161 | return tqdm(*args, **kwargs) 162 | -------------------------------------------------------------------------------- /tests/test_activities.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import json 5 | import os 6 | import zipfile 7 | from pathlib import Path 8 | from unittest.mock import Mock, patch 9 | 10 | import pytest 11 | from conftest import get_mocked_response 12 | 13 | from garpy import Activities, Activity 14 | 15 | RESPONSE_EXAMPLES_PATH = Path(__file__).parent / "response_examples" 16 | 17 | 18 | class TestActivity: 19 | """activities.Activity""" 20 | 21 | def test_from_garmin_connect(self, client_activities): 22 | with client_activities: 23 | activity = Activity.from_garmin_connect(9766544337, client_activities) 24 | assert isinstance(activity, Activity) 25 | assert activity.id == 9766544337 26 | assert activity.type == "cycling" 27 | assert activity.name == "Morning ride" 28 | 29 | client_activities.get_activity.assert_called_once() 30 | client_activities.get_activity.assert_called_with(9766544337, "summary") 31 | 32 | def test_from_garmin_summary(self): 33 | summary = json.loads( 34 | (RESPONSE_EXAMPLES_PATH / "summary_9766544337.json").read_text() 35 | ) 36 | 37 | activity = Activity.from_garmin_summary(summary) 38 | assert isinstance(activity, Activity) 39 | assert activity.id == 9766544337 40 | assert activity.type == "cycling" 41 | assert activity.name == "Morning ride" 42 | 43 | def test_from_garmin_activity_list_entry(self): 44 | activities = json.loads( 45 | (RESPONSE_EXAMPLES_PATH / "list_activities.json").read_text() 46 | ) 47 | 48 | activity = Activity.from_garmin_activity_list_entry(activities[0]) 49 | assert isinstance(activity, Activity) 50 | assert activity.id == 2532452238 51 | assert activity.type == "walking" 52 | assert activity.name == "Random walking" 53 | 54 | def test_filepath_awareness(self, activity, tmp_path): 55 | expected_base_filename = "2018-11-24T09:30:00+00:00_2532452238" 56 | if os.name == "nt": 57 | expected_base_filename = expected_base_filename.replace(":", ".") 58 | assert activity.base_filename == expected_base_filename 59 | assert activity.get_export_filepath(tmp_path, "gpx") == tmp_path / ( 60 | expected_base_filename + ".gpx" 61 | ) 62 | 63 | with pytest.raises(ValueError) as excinfo: 64 | activity.get_export_filepath(tmp_path, "unknown_format") 65 | assert "Format 'unknown_format' unknown." in str(excinfo.value) 66 | 67 | def test_filename_windows(self, activity): 68 | with patch("os.name", "nt"): 69 | expected_base_filename = "2018-11-24T09:30:00+00:00_2532452238".replace( 70 | ":", "." 71 | ) 72 | assert activity.base_filename == expected_base_filename 73 | 74 | def test_download_gpx(self, activity, client_activities, tmp_path): 75 | with client_activities: 76 | fmt = "gpx" 77 | activity.download(client_activities, fmt, tmp_path) 78 | client_activities.get_activity.assert_called_with(activity.id, fmt) 79 | client_activities.get_activity.assert_called_once() 80 | expected_downloaded_file_path = activity.get_export_filepath(tmp_path, fmt) 81 | assert expected_downloaded_file_path.exists() 82 | assert not (Path(tmp_path) / ".not_found").exists() 83 | assert ( 84 | expected_downloaded_file_path.read_text() 85 | == f"Trust me, this is a {fmt!r} file for activity {activity.id!r}" 86 | ) 87 | 88 | def test_download_original(self, activity, client_activities, tmp_path): 89 | with client_activities: 90 | fmt = "original" 91 | activity.download(client_activities, fmt, tmp_path) 92 | client_activities.get_activity.assert_called_with(activity.id, fmt) 93 | client_activities.get_activity.assert_called_once() 94 | expected_downloaded_file_path = activity.get_export_filepath(tmp_path, fmt) 95 | assert expected_downloaded_file_path.exists() 96 | assert not (Path(tmp_path) / ".not_found").exists() 97 | zipped_file = zipfile.ZipFile( 98 | RESPONSE_EXAMPLES_PATH / "example_original_with_fit.zip", mode="r" 99 | ) 100 | fit_inside_original_zip = zipped_file.open(zipped_file.namelist()[0]).read() 101 | assert expected_downloaded_file_path.read_bytes() == fit_inside_original_zip 102 | 103 | def test_download_inexistent_gpx(self, activity, client, tmp_path): 104 | with client: 105 | client.session.get = Mock( 106 | return_value=get_mocked_response(404), func_name="client.session.get()" 107 | ) 108 | fmt = "gpx" 109 | activity.download(client, fmt, tmp_path) 110 | expected_downloaded_file_path = activity.get_export_filepath(tmp_path, fmt) 111 | assert not expected_downloaded_file_path.exists() 112 | assert (Path(tmp_path) / ".not_found").exists() 113 | assert ( 114 | expected_downloaded_file_path.name 115 | in (Path(tmp_path) / ".not_found").read_text() 116 | ) 117 | 118 | def test_download_manually_uploaded_gpx(self, client_activities, tmp_path): 119 | with client_activities: 120 | activities = Activities.list(client_activities) 121 | activity = activities[1] 122 | fmt = "original" 123 | activity.download(client_activities, fmt, tmp_path) 124 | # A GPX should have been downloaded 125 | expected_downloaded_file_path = activity.get_export_filepath( 126 | tmp_path, "gpx" 127 | ) 128 | assert expected_downloaded_file_path.exists() 129 | 130 | # The default FIT format for 'original' should not exist and be listed on .not_found 131 | fit_file_path = activity.get_export_filepath(tmp_path, "original") 132 | assert not fit_file_path.exists() 133 | assert (Path(tmp_path) / ".not_found").exists() 134 | assert fit_file_path.name in (Path(tmp_path) / ".not_found").read_text() 135 | 136 | 137 | class TestActivities: 138 | """activities.Activities""" 139 | 140 | def test_list(self, client_activities): 141 | 142 | activities = Activities.list(client_activities) 143 | 144 | assert isinstance(activities, Activities) 145 | assert activities[0].id == 2532452238 146 | assert activities[0].type == "walking" 147 | assert activities[0].name == "Random walking" 148 | assert len(activities) == 10 149 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /tests/test_download.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from pathlib import Path 5 | from unittest.mock import Mock 6 | 7 | from conftest import get_mocked_response 8 | 9 | from garpy import Activities, ActivitiesDownloader, Activity 10 | from garpy.download import DEFAULT_FORMATS 11 | 12 | RESPONSE_EXAMPLES_PATH = Path(__file__).parent / "response_examples" 13 | 14 | 15 | class TestActivitiesDownloader: 16 | """download.ActivitiesDownloader""" 17 | 18 | def test_backup_dir_with_path(self, client, tmp_path): 19 | downloader = ActivitiesDownloader(client, tmp_path) 20 | assert downloader.backup_dir.exists() 21 | assert downloader.backup_dir == tmp_path 22 | 23 | def test_backup_dir_with_str(self, client, tmp_path): 24 | downloader = ActivitiesDownloader(client, str(tmp_path)) 25 | assert isinstance(downloader.backup_dir, Path) 26 | assert downloader.backup_dir.exists() 27 | assert downloader.backup_dir == tmp_path 28 | 29 | def test_backup_dir_inexistent(self, client, tmp_path): 30 | tmp_path.rmdir() 31 | assert not tmp_path.exists() 32 | downloader = ActivitiesDownloader(client, str(tmp_path)) 33 | assert downloader.backup_dir.exists() 34 | assert downloader.backup_dir == tmp_path 35 | 36 | def test_existing_files_is_empty(self, client, tmp_path): 37 | downloader = ActivitiesDownloader(client, tmp_path) 38 | assert downloader.existing_files == set() 39 | 40 | def test_existing_files_has_file_after_download(self, activity, client, tmp_path): 41 | with client: 42 | client.session.get = Mock( 43 | return_value=get_mocked_response( 44 | 200, text="Trust me, this is a GPX file" 45 | ), 46 | func_name="client.session.get()", 47 | ) 48 | fmt = "gpx" 49 | activity.download(client, fmt, tmp_path) 50 | 51 | downloader = ActivitiesDownloader(client, tmp_path) 52 | assert downloader.existing_files == { 53 | activity.get_export_filepath(tmp_path, fmt) 54 | } 55 | 56 | def test_not_found_inexistent(self, client, tmp_path): 57 | downloader = ActivitiesDownloader(client, str(tmp_path)) 58 | assert not (downloader.backup_dir / ".not_found").exists() 59 | assert downloader.not_found == set() 60 | 61 | def test_not_found_empty(self, client, tmp_path): 62 | downloader = ActivitiesDownloader(client, str(tmp_path)) 63 | (downloader.backup_dir / ".not_found").touch() 64 | assert (downloader.backup_dir / ".not_found").exists() 65 | assert downloader.not_found == set() 66 | 67 | def test_not_found_has_file_after_failed_download(self, activity, client, tmp_path): 68 | with client: 69 | client.session.get = Mock( 70 | return_value=get_mocked_response(404), func_name="client.session.get()" 71 | ) 72 | fmt = "gpx" 73 | activity.download(client, fmt, tmp_path) 74 | 75 | downloader = ActivitiesDownloader(client, tmp_path) 76 | assert downloader.not_found == {activity.get_export_filepath(tmp_path, fmt)} 77 | 78 | def test_discover_formats_to_download_with_backup_from_scratch( 79 | self, client_activities, tmp_path 80 | ): 81 | activities = client_activities.list_activities() 82 | assert len(activities) == 10 83 | with client_activities: 84 | downloader = ActivitiesDownloader(client_activities, tmp_path) 85 | to_download = downloader._discover_formats_to_download( 86 | Activities.list(client_activities) 87 | ) 88 | 89 | assert len(to_download) == 10 90 | for activity, formats in to_download.items(): 91 | assert set(formats) == set(DEFAULT_FORMATS) 92 | 93 | def test_discover_formats_to_download_with_incremental_backup( 94 | self, client_activities, tmp_path 95 | ): 96 | activities = client_activities.list_activities() 97 | assert len(activities) == 10 98 | with client_activities: 99 | activity = Activity.from_garmin_activity_list_entry(activities[0]) 100 | for fmt in DEFAULT_FORMATS: 101 | activity.download(client_activities, fmt, tmp_path) 102 | downloader = ActivitiesDownloader(client_activities, tmp_path) 103 | to_download = downloader._discover_formats_to_download( 104 | Activities.list(client_activities) 105 | ) 106 | 107 | assert len(to_download) == 9 108 | for activity, formats in to_download.items(): 109 | assert set(formats) == set(DEFAULT_FORMATS) 110 | 111 | def test_discover_formats_to_download_with_not_found( 112 | self, client_activities, tmp_path 113 | ): 114 | activities = client_activities.list_activities() 115 | assert len(activities) == 10 116 | with client_activities: 117 | # Download one activity manually first 118 | activity = Activity.from_garmin_activity_list_entry(activities[0]) 119 | (tmp_path / ".not_found").write_text( 120 | str(activity.get_export_filepath(tmp_path, "gpx")) 121 | ) 122 | 123 | # Discover what should be downloaded 124 | downloader = ActivitiesDownloader(client_activities, tmp_path) 125 | to_download = downloader._discover_formats_to_download( 126 | Activities.list(client_activities) 127 | ) 128 | 129 | assert len(to_download) == 10 130 | for activity, formats in to_download.items(): 131 | if len(formats) < len(DEFAULT_FORMATS): 132 | assert "gpx" not in formats 133 | assert set(formats) <= set(DEFAULT_FORMATS) 134 | else: 135 | assert set(formats) == set(DEFAULT_FORMATS) 136 | 137 | def test_discover_formats_to_download_with_backup_up_to_date( 138 | self, client_activities, tmp_path 139 | ): 140 | activities = client_activities.list_activities() 141 | assert len(activities) == 10 142 | with client_activities: 143 | # Download everything manually first 144 | for activity_entry in activities: 145 | activity = Activity.from_garmin_activity_list_entry(activity_entry) 146 | for fmt in DEFAULT_FORMATS: 147 | activity.download(client_activities, fmt, tmp_path) 148 | 149 | # Discover what should be downloaded 150 | downloader = ActivitiesDownloader(client_activities, tmp_path) 151 | to_download = downloader._discover_formats_to_download( 152 | Activities.list(client_activities) 153 | ) 154 | 155 | assert len(to_download) == 0 156 | 157 | def test_download_with_backup_from_scratch(self, client_activities, tmp_path): 158 | assert len(list(tmp_path.glob("*"))) == 0 159 | with client_activities: 160 | # Discover what should be downloaded 161 | downloader = ActivitiesDownloader(client_activities, tmp_path) 162 | activities = Activities.list(client_activities) 163 | downloader.download_all(activities) 164 | assert len(list(tmp_path.glob("*"))) == len(activities) * len( 165 | DEFAULT_FORMATS 166 | ) 167 | 168 | def test_download_with_backup_up_to_date(self, client_activities, tmp_path): 169 | activities = client_activities.list_activities() 170 | assert len(list(tmp_path.glob("*"))) == 0 171 | with client_activities: 172 | # Download everything manually first 173 | for activity_entry in activities: 174 | activity = Activity.from_garmin_activity_list_entry(activity_entry) 175 | for fmt in DEFAULT_FORMATS: 176 | activity.download(client_activities, fmt, tmp_path) 177 | 178 | assert len(list(tmp_path.glob("*"))) == len(activities) * len( 179 | DEFAULT_FORMATS 180 | ) 181 | downloader = ActivitiesDownloader(client_activities, tmp_path) 182 | downloader.download_all(Activities.list(client_activities)) 183 | assert len(list(tmp_path.glob("*"))) == len(activities) * len( 184 | DEFAULT_FORMATS 185 | ) 186 | 187 | def test_download_with_backup_up_to_date_and_files_not_found( 188 | self, client_activities, tmp_path 189 | ): 190 | activities = client_activities.list_activities() 191 | assert len(list(tmp_path.glob("*"))) == 0 192 | with client_activities: 193 | # Download everything manually first and fake that GPX was not found for all activities 194 | for activity_entry in activities: 195 | activity = Activity.from_garmin_activity_list_entry(activity_entry) 196 | for fmt in DEFAULT_FORMATS: 197 | if fmt == "gpx": 198 | with open( 199 | str(Path(tmp_path) / ".not_found"), mode="a" 200 | ) as not_found: 201 | not_found.write( 202 | str(activity.get_export_filepath(tmp_path, fmt).name) 203 | + "\n" 204 | ) 205 | else: 206 | activity.download(client_activities, fmt, tmp_path) 207 | 208 | assert ( 209 | len(list(tmp_path.glob("*"))) 210 | == len(activities) * (len(DEFAULT_FORMATS) - 1) + 1 211 | ) 212 | downloader = ActivitiesDownloader(client_activities, tmp_path) 213 | downloader.download_all(Activities.list(client_activities)) 214 | assert ( 215 | len(list(tmp_path.glob("*"))) 216 | == len(activities) * (len(DEFAULT_FORMATS) - 1) + 1 217 | ) 218 | 219 | def test_download_with_files_not_found(self, client_activities, tmp_path): 220 | activities = client_activities.list_activities() 221 | assert len(list(tmp_path.glob("*"))) == 0 222 | with client_activities: 223 | # Fake that GPX was not found for all activities 224 | for activity_entry in activities: 225 | activity = Activity.from_garmin_activity_list_entry(activity_entry) 226 | for fmt in DEFAULT_FORMATS: 227 | if fmt == "gpx": 228 | with open( 229 | str(Path(tmp_path) / ".not_found"), mode="a" 230 | ) as not_found: 231 | not_found.write( 232 | str(activity.get_export_filepath(tmp_path, fmt).name) 233 | + "\n" 234 | ) 235 | 236 | assert ( 237 | len(list(tmp_path.glob("*"))) == 1 238 | ), "There should be a '.not_found' file in the backup directory" 239 | downloader = ActivitiesDownloader(client_activities, tmp_path) 240 | downloader.download_all(Activities.list(client_activities)) 241 | assert ( 242 | len(list(tmp_path.glob("*"))) 243 | == len(activities) * (len(DEFAULT_FORMATS) - 1) + 1 244 | ) 245 | 246 | def test_download_with_files_not_found_and_some_backed_up( 247 | self, client_activities, tmp_path 248 | ): 249 | activities = client_activities.list_activities() 250 | assert len(list(tmp_path.glob("*"))) == 0 251 | with client_activities: 252 | # Fake that GPX was not found for all activities 253 | for activity_entry in activities[:5]: 254 | activity = Activity.from_garmin_activity_list_entry(activity_entry) 255 | for fmt in DEFAULT_FORMATS: 256 | if fmt == "gpx": 257 | with open( 258 | str(Path(tmp_path) / ".not_found"), mode="a" 259 | ) as not_found: 260 | not_found.write( 261 | str(activity.get_export_filepath(tmp_path, fmt).name) 262 | + "\n" 263 | ) 264 | else: 265 | activity.download(client_activities, fmt, tmp_path) 266 | 267 | assert len(list(tmp_path.glob("*"))) == 5 * (len(DEFAULT_FORMATS) - 1) + 1 268 | downloader = ActivitiesDownloader(client_activities, tmp_path) 269 | downloader.download_all(Activities.list(client_activities)) 270 | assert ( 271 | len(list(tmp_path.glob("*"))) 272 | == len(activities) * (len(DEFAULT_FORMATS) - 1) + 5 + 1 273 | ) 274 | 275 | def test_download_one_activity_with_backup_from_scratch( 276 | self, client_activities, tmp_path 277 | ): 278 | assert len(list(tmp_path.glob("*"))) == 0 279 | with client_activities: 280 | activity = Activity.from_garmin_connect(9766544337, client_activities) 281 | 282 | # Discover what should be downloaded 283 | downloader = ActivitiesDownloader(client_activities, tmp_path) 284 | downloader.download_one(activity) 285 | assert len(list(tmp_path.glob("*"))) == len(DEFAULT_FORMATS) 286 | 287 | def test_call_for_all_activities(self, client_activities, tmp_path): 288 | assert len(list(tmp_path.glob("*"))) == 0 289 | with client_activities: 290 | # Discover what should be downloaded 291 | downloader = ActivitiesDownloader(client_activities, tmp_path) 292 | downloader() 293 | assert len(list(tmp_path.glob("*"))) == len( 294 | client_activities.list_activities() 295 | ) * len(DEFAULT_FORMATS) 296 | 297 | def test_call_for_one_activity(self, client_activities, tmp_path): 298 | assert len(list(tmp_path.glob("*"))) == 0 299 | with client_activities: 300 | # Discover what should be downloaded 301 | downloader = ActivitiesDownloader(client_activities, tmp_path) 302 | downloader(activity_id=9766544337) 303 | assert len(list(tmp_path.glob("*"))) == len(DEFAULT_FORMATS) 304 | --------------------------------------------------------------------------------